Skip to content

Commit 58ae03e

Browse files
feat(miniapp-agent): add observe capability for watching settled turns
- Introduced observe/cancelObserve methods on MiniAppAgentCapability contract - Added bridge-side watch mechanism with OBSERVE, OBSERVE_EVENT, OBSERVE_END messages - Implemented observations module in gateway to publish turn settlements to watchers - Added observeSource async generator handler for streaming observation frames - Registered watchMiniAppAgentObservationsOperation with validation and stream support - Extended cli-service with observeMiniAppAgent and cancelMiniAppAgentObserve passthrough - Added optional agent member resolution so hosts without observe degrade gracefully - Added V1 Mavis Cron adapter bridge for legacy tool compatibility with V2 Cron owner - Added window state bridge API with request/response and change listeners - Removed panel icon-button pressed/expanded fill that conflicted with hover feedback - Added composer mascot positioning styles and WebuiIconApps icon component
1 parent 7968187 commit 58ae03e

50 files changed

Lines changed: 2961 additions & 85 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎packages/local-runtime-v2/src/application/session/process-local-application-contract.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ import type {
3131
MiniAppAgentModels,
3232
MiniAppAgentModelSelection,
3333
MiniAppAgentPermissionDecision,
34+
MiniAppAgentObserveEvent,
3435
MiniAppAgentRunEvent,
3536
MiniAppAgentRunResult,
3637
MiniAppAgentSessionContext,
@@ -278,6 +279,25 @@ export interface LocalRuntimeApplication {
278279
readonly caller: ProcessLocalMiniAppAgentCaller;
279280
readonly agent?: string;
280281
}): Promise<{ readonly sessionId: string }>;
282+
/**
283+
* Watch turns as they settle, for every conversation or one.
284+
*
285+
* Long-lived: the promise has no result, it settles when the watch ends. `requestId`
286+
* is the handle the page cancels with, so it must be unique across watches.
287+
*/
288+
observe(input: {
289+
readonly caller: ProcessLocalMiniAppAgentCaller;
290+
readonly requestId: string;
291+
readonly all?: boolean;
292+
readonly sessionId?: string;
293+
readonly signal?: AbortSignal;
294+
readonly onEvent: (event: MiniAppAgentObserveEvent) => void;
295+
}): Promise<void>;
296+
/** Ends the watch this plugin opened under this id. */
297+
cancelObserve(input: {
298+
readonly caller: ProcessLocalMiniAppAgentCaller;
299+
readonly requestId: string;
300+
}): boolean;
281301
reply(input: {
282302
readonly caller: ProcessLocalMiniAppAgentCaller;
283303
readonly sessionId: string;

‎packages/local-runtime-v2/src/application/session/process-local-miniapp-agent-application.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,8 @@ export function createProcessLocalMiniAppAgentApplication(
2828
| 'sessions'
2929
| 'session'
3030
| 'create'
31+
| 'observe'
32+
| 'cancelObserve'
3133
>,
3234
): ProcessLocalMiniAppAgentApplication {
3335
return {
@@ -61,5 +63,17 @@ export function createProcessLocalMiniAppAgentApplication(
6163
gateway.panelOpened({ sessionId, hostSessionId }),
6264
panelClosed: async ({ sessionId, hostSessionId }) =>
6365
gateway.panelClosed({ sessionId, hostSessionId }),
66+
observe: ({ caller, requestId, all, sessionId, signal, onEvent }) =>
67+
gateway.observe(
68+
toGatewayCaller(caller),
69+
{
70+
requestId,
71+
...(all === true ? { all: true } : {}),
72+
...(sessionId !== undefined ? { sessionId } : {}),
73+
},
74+
{ ...(signal ? { signal } : {}), onEvent },
75+
),
76+
cancelObserve: ({ caller, requestId }) =>
77+
gateway.cancelObserve(toGatewayCaller(caller), requestId),
6478
};
6579
}
Lines changed: 218 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,218 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type {
4+
CronDefinitionView,
5+
CronRunView,
6+
} from "../../service/cron/contracts.js";
7+
import {
8+
createV1MavisCronAdapterBridge,
9+
type V1CronAgentToolService,
10+
} from "./cron.js";
11+
12+
function definition(overrides: Partial<CronDefinitionView> = {}): CronDefinitionView {
13+
return {
14+
cronId: "cron-1",
15+
name: "count-projects",
16+
agentName: "mavis",
17+
schedule: {
18+
kind: "recurring",
19+
expression: "0 9 * * *",
20+
timezone: "Asia/Shanghai",
21+
},
22+
enabled: true,
23+
armed: true,
24+
prompt: "数一下项目数量",
25+
sessionTarget: { mode: "new" },
26+
project: null,
27+
model: null,
28+
nextRunAtMs: 1_800_000_000_000,
29+
createdAtMs: 1_799_000_000_000,
30+
updatedAtMs: 1_799_500_000_000,
31+
...overrides,
32+
};
33+
}
34+
35+
function run(overrides: Partial<CronRunView> = {}): CronRunView {
36+
return {
37+
runId: "run-1",
38+
cronId: "cron-1",
39+
triggerSource: "manual",
40+
status: "delivered",
41+
sessionId: "mvs_child",
42+
createdAtMs: 1_799_600_000_000,
43+
deliveredAtMs: 1_799_600_500_000,
44+
...overrides,
45+
};
46+
}
47+
48+
/** Minimal Cron owner: the bridge only forwards to these members. */
49+
function cronOwner(overrides: Partial<V1CronAgentToolService> = {}) {
50+
const calls: Record<string, unknown[]> = {};
51+
const record =
52+
<T,>(name: string, impl: (input: T) => unknown) =>
53+
(input: T) => {
54+
(calls[name] ??= []).push(input);
55+
return impl(input) as never;
56+
};
57+
const owner: V1CronAgentToolService = {
58+
listDefinitions: record("listDefinitions", () => ({
59+
items: [definition()],
60+
hasMore: false,
61+
})),
62+
getDefinition: record("getDefinition", () => definition()),
63+
createDefinition: record("createDefinition", (command) => definition()),
64+
updateDefinition: record("updateDefinition", () => definition()),
65+
deleteDefinition: record("deleteDefinition", () => undefined),
66+
triggerManualRun: record("triggerManualRun", () => run()),
67+
listRuns: record("listRuns", () => ({ items: [run()], hasMore: false })),
68+
...overrides,
69+
} as V1CronAgentToolService;
70+
return { owner, calls };
71+
}
72+
73+
describe("v1 Mavis Cron adapter bridge", () => {
74+
it("lists Cron definitions as Mavis Cron tasks and forwards the agent filter", async () => {
75+
const { owner, calls } = cronOwner();
76+
const bridge = createV1MavisCronAdapterBridge();
77+
bridge.bindService(owner);
78+
79+
const listed = await bridge.adapter.listCrons({ agentName: "mavis" });
80+
81+
expect(calls.listDefinitions).toEqual([{ agentName: "mavis" }]);
82+
expect(listed.tasks).toEqual([
83+
{
84+
cronId: "cron-1",
85+
cronName: "count-projects",
86+
agentName: "mavis",
87+
scheduleType: "cron",
88+
schedule: "0 9 * * *",
89+
timezone: "Asia/Shanghai",
90+
enabled: true,
91+
prompt: "数一下项目数量",
92+
session: { mode: "new" },
93+
nextRun: 1_800_000_000_000,
94+
status: "active",
95+
},
96+
]);
97+
expect(listed.hasMore).toBe(false);
98+
});
99+
100+
it("creates recurring, self-reminder and one-shot definitions through the Cron owner", async () => {
101+
const { owner, calls } = cronOwner();
102+
const bridge = createV1MavisCronAdapterBridge();
103+
bridge.bindService(owner);
104+
105+
await bridge.adapter.createCron({
106+
agentName: "mavis",
107+
cronName: "count-projects",
108+
schedule: "0 9 * * *",
109+
prompt: "数一下项目数量",
110+
timezone: "Asia/Shanghai",
111+
session: { mode: "new" },
112+
});
113+
await bridge.adapter.createSelfReminder({
114+
agentName: "mavis",
115+
sessionId: "mvs_self",
116+
every: "1h",
117+
prompt: "检查 CI",
118+
});
119+
await bridge.adapter.createOnceCron({
120+
agentName: "mavis",
121+
cronName: "once-demo",
122+
after: "10m",
123+
prompt: "提醒我",
124+
session: { mode: "new" },
125+
});
126+
127+
expect(calls.createDefinition).toHaveLength(3);
128+
expect(calls.createDefinition?.[0]).toMatchObject({
129+
name: "count-projects",
130+
agentName: "mavis",
131+
schedule: {
132+
kind: "recurring",
133+
expression: "0 9 * * *",
134+
timezone: "Asia/Shanghai",
135+
},
136+
prompt: "数一下项目数量",
137+
sessionTarget: { mode: "new" },
138+
});
139+
expect(calls.createDefinition?.[1]).toMatchObject({
140+
agentName: "mavis",
141+
schedule: { kind: "recurring", expression: "0 */1 * * *" },
142+
sessionTarget: { mode: "sessionId", sessionId: "mvs_self" },
143+
});
144+
expect(calls.createDefinition?.[2]).toMatchObject({
145+
name: "once-demo",
146+
schedule: { kind: "once" },
147+
prompt: "提醒我",
148+
sessionTarget: { mode: "new" },
149+
});
150+
});
151+
152+
it("reads, updates, deletes and triggers a definition, and reports run history", async () => {
153+
const { owner, calls } = cronOwner();
154+
const bridge = createV1MavisCronAdapterBridge();
155+
bridge.bindService(owner);
156+
157+
const fetched = await bridge.adapter.getCron({ cronId: "cron-1" });
158+
await bridge.adapter.updateCron({
159+
cronId: "cron-1",
160+
schedule: "*/5 * * * *",
161+
enabled: false,
162+
});
163+
const deleted = await bridge.adapter.deleteCron({ cronId: "cron-1" });
164+
const triggered = await bridge.adapter.triggerCron({ cronId: "cron-1" });
165+
const history = await bridge.adapter.listCronSessions({ cronId: "cron-1" });
166+
167+
expect(fetched.task).toMatchObject({
168+
cronId: "cron-1",
169+
cronName: "count-projects",
170+
scheduleType: "cron",
171+
status: "active",
172+
});
173+
expect(calls.updateDefinition?.[0]).toMatchObject({
174+
cronId: "cron-1",
175+
schedule: { kind: "recurring", expression: "*/5 * * * *" },
176+
enabled: false,
177+
});
178+
expect(calls.deleteDefinition).toEqual([{ cronId: "cron-1" }]);
179+
expect(deleted).toEqual({ success: true });
180+
expect(calls.triggerManualRun).toEqual(["cron-1"]);
181+
expect(triggered).toMatchObject({
182+
success: true,
183+
runId: "run-1",
184+
sessionId: "mvs_child",
185+
status: "delivered",
186+
triggerSource: "manual",
187+
});
188+
expect(calls.listRuns).toEqual([{ cronId: "cron-1" }]);
189+
expect(history.sessions).toEqual([
190+
{
191+
runId: "run-1",
192+
createdAt: 1_799_600_000_000,
193+
status: "delivered",
194+
triggerSource: "manual",
195+
sessionId: "mvs_child",
196+
deliveredAt: 1_799_600_500_000,
197+
},
198+
]);
199+
});
200+
201+
it("answers CRON_UNAVAILABLE until the Cron owner is bound", async () => {
202+
const bridge = createV1MavisCronAdapterBridge();
203+
204+
await expect(bridge.adapter.listCrons({})).rejects.toMatchObject({
205+
code: "CRON_UNAVAILABLE",
206+
});
207+
});
208+
209+
it("refuses to bind a second Cron owner", () => {
210+
const { owner } = cronOwner();
211+
const bridge = createV1MavisCronAdapterBridge();
212+
bridge.bindService(owner);
213+
214+
expect(() => bridge.bindService(owner)).toThrow(
215+
"Cron service is already bound",
216+
);
217+
});
218+
});

0 commit comments

Comments
 (0)