@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/activation-funnel-telemetry.md +310 -0
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +3 -18
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +78 -78
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +25 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- package/src/acp/feature-gate.ts +0 -34
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
4
|
+
|
|
5
|
+
// Silence the logger.
|
|
6
|
+
mock.module("../util/logger.js", () => ({
|
|
7
|
+
getLogger: () =>
|
|
8
|
+
new Proxy({} as Record<string, unknown>, {
|
|
9
|
+
get: () => () => {},
|
|
10
|
+
}),
|
|
11
|
+
}));
|
|
12
|
+
|
|
13
|
+
// Usage-data collection is enabled so recordActivationEvent writes rows.
|
|
14
|
+
mock.module("../config/loader.js", () => ({
|
|
15
|
+
getConfig: () => ({ collectUsageData: true }),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
let broadcastedMessages: ServerMessage[] = [];
|
|
19
|
+
const realEventHub = await import("../runtime/assistant-event-hub.js");
|
|
20
|
+
mock.module("../runtime/assistant-event-hub.js", () => ({
|
|
21
|
+
...realEventHub,
|
|
22
|
+
broadcastMessage: (msg: ServerMessage) => broadcastedMessages.push(msg),
|
|
23
|
+
}));
|
|
24
|
+
|
|
25
|
+
// Stub the child-conversation launcher so the launch_conversation commit path
|
|
26
|
+
// runs without spinning up a real conversation.
|
|
27
|
+
mock.module("../daemon/conversation-launch.js", () => ({
|
|
28
|
+
launchConversation: async () => ({ conversationId: "spawned-conv" }),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const { createSurfaceMutex, handleSurfaceAction, surfaceProxyResolver } =
|
|
32
|
+
await import("../daemon/conversation-surfaces.js");
|
|
33
|
+
|
|
34
|
+
import type { SurfaceConversationContext } from "../daemon/conversation-surfaces.js";
|
|
35
|
+
import type { SurfaceType, UiSurfaceShow } from "../daemon/message-protocol.js";
|
|
36
|
+
import {
|
|
37
|
+
isActivationSession,
|
|
38
|
+
markActivationSession,
|
|
39
|
+
} from "../memory/activation-session-store.js";
|
|
40
|
+
import { getDb } from "../memory/db-connection.js";
|
|
41
|
+
import { initializeDb } from "../memory/db-init.js";
|
|
42
|
+
import { queryUnreportedOnboardingEvents } from "../memory/onboarding-events-store.js";
|
|
43
|
+
import { activationSessions, onboardingEvents } from "../memory/schema.js";
|
|
44
|
+
|
|
45
|
+
initializeDb();
|
|
46
|
+
|
|
47
|
+
interface ProcessMessageCall {
|
|
48
|
+
content: string;
|
|
49
|
+
activeSurfaceId?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function makeContext(
|
|
53
|
+
conversationId: string,
|
|
54
|
+
sent: ServerMessage[] = [],
|
|
55
|
+
): SurfaceConversationContext & {
|
|
56
|
+
processMessageCalls: ProcessMessageCall[];
|
|
57
|
+
} {
|
|
58
|
+
const processMessageCalls: ProcessMessageCall[] = [];
|
|
59
|
+
return {
|
|
60
|
+
conversationId,
|
|
61
|
+
traceEmitter: { emit: () => {} },
|
|
62
|
+
sendToClient: (msg: ServerMessage) => sent.push(msg),
|
|
63
|
+
pendingSurfaceActions: new Map<string, { surfaceType: SurfaceType }>(),
|
|
64
|
+
lastSurfaceAction: new Map<
|
|
65
|
+
string,
|
|
66
|
+
{ actionId: string; data?: Record<string, unknown> }
|
|
67
|
+
>(),
|
|
68
|
+
surfaceState: new Map(),
|
|
69
|
+
surfaceUndoStacks: new Map<string, string[]>(),
|
|
70
|
+
accumulatedSurfaceState: new Map<string, Record<string, unknown>>(),
|
|
71
|
+
surfaceActionRequestIds: new Set<string>(),
|
|
72
|
+
currentTurnSurfaces: [],
|
|
73
|
+
isProcessing: () => false,
|
|
74
|
+
enqueueMessage: () => ({ queued: false, requestId: "req-1" }),
|
|
75
|
+
getQueueDepth: () => 0,
|
|
76
|
+
processMessage: async (options) => {
|
|
77
|
+
processMessageCalls.push({
|
|
78
|
+
content: options.content,
|
|
79
|
+
activeSurfaceId: options.activeSurfaceId,
|
|
80
|
+
});
|
|
81
|
+
return "msg-1";
|
|
82
|
+
},
|
|
83
|
+
withSurface: createSurfaceMutex(),
|
|
84
|
+
processMessageCalls,
|
|
85
|
+
} as SurfaceConversationContext & {
|
|
86
|
+
processMessageCalls: ProcessMessageCall[];
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function resetTables(): void {
|
|
91
|
+
getDb().delete(onboardingEvents).run();
|
|
92
|
+
getDb().delete(activationSessions).run();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** Render a choice surface tagged (or not) with an activation_moment. */
|
|
96
|
+
async function showTaggedChoice(
|
|
97
|
+
ctx: SurfaceConversationContext,
|
|
98
|
+
sent: ServerMessage[],
|
|
99
|
+
activationMoment?: string,
|
|
100
|
+
): Promise<string> {
|
|
101
|
+
await surfaceProxyResolver(ctx, "ui_show", {
|
|
102
|
+
surface_type: "choice",
|
|
103
|
+
title: "Pick an outcome",
|
|
104
|
+
data: {
|
|
105
|
+
options: [
|
|
106
|
+
{ id: "inbox", title: "Clean up my inbox" },
|
|
107
|
+
{ id: "calendar", title: "Plan my week" },
|
|
108
|
+
],
|
|
109
|
+
},
|
|
110
|
+
...(activationMoment ? { activation_moment: activationMoment } : {}),
|
|
111
|
+
});
|
|
112
|
+
const showMessage = sent.find(
|
|
113
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
114
|
+
) as UiSurfaceShow;
|
|
115
|
+
return showMessage.surfaceId;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
describe("activation moment emission from ui_show surface commits", () => {
|
|
119
|
+
beforeEach(() => {
|
|
120
|
+
broadcastedMessages = [];
|
|
121
|
+
resetTables();
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
test("committed tagged surface in a marked session writes exactly one row", async () => {
|
|
125
|
+
markActivationSession("conv-marked");
|
|
126
|
+
expect(isActivationSession("conv-marked")).toBe(true);
|
|
127
|
+
|
|
128
|
+
const sent: ServerMessage[] = [];
|
|
129
|
+
const ctx = makeContext("conv-marked", sent);
|
|
130
|
+
const surfaceId = await showTaggedChoice(ctx, sent, "moment_2");
|
|
131
|
+
|
|
132
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
133
|
+
choiceId: "inbox",
|
|
134
|
+
selectedIds: ["inbox"],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
138
|
+
expect(rows).toHaveLength(1);
|
|
139
|
+
expect(rows[0]!.stepName).toBe("activation_moment_2_complete");
|
|
140
|
+
expect(rows[0]!.stepIndex).toBe(2);
|
|
141
|
+
expect(rows[0]!.sessionId).toBe("conv-marked");
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test("a queue-rejected commit does NOT emit; the tag survives for the retry", async () => {
|
|
145
|
+
markActivationSession("conv-rejected");
|
|
146
|
+
const sent: ServerMessage[] = [];
|
|
147
|
+
const ctx = makeContext("conv-rejected", sent);
|
|
148
|
+
const surfaceId = await showTaggedChoice(ctx, sent, "moment_2");
|
|
149
|
+
|
|
150
|
+
// First click while the queue is full: enqueue rejects, action not
|
|
151
|
+
// accepted. Must NOT record a milestone, and must leave the one-shot tag
|
|
152
|
+
// intact so the user's retry still emits.
|
|
153
|
+
ctx.enqueueMessage = () => ({
|
|
154
|
+
queued: false,
|
|
155
|
+
requestId: "req-rejected",
|
|
156
|
+
rejected: true,
|
|
157
|
+
});
|
|
158
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
159
|
+
choiceId: "inbox",
|
|
160
|
+
selectedIds: ["inbox"],
|
|
161
|
+
});
|
|
162
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
163
|
+
|
|
164
|
+
// Retry is accepted → records exactly one row.
|
|
165
|
+
ctx.enqueueMessage = () => ({ queued: false, requestId: "req-ok" });
|
|
166
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
167
|
+
choiceId: "inbox",
|
|
168
|
+
selectedIds: ["inbox"],
|
|
169
|
+
});
|
|
170
|
+
const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
171
|
+
expect(rows).toHaveLength(1);
|
|
172
|
+
expect(rows[0]!.stepName).toBe("activation_moment_2_complete");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("first_wow_executed records at SHOW time (no commit) and never double-emits", async () => {
|
|
176
|
+
markActivationSession("conv-wow");
|
|
177
|
+
const sent: ServerMessage[] = [];
|
|
178
|
+
const ctx = makeContext("conv-wow", sent);
|
|
179
|
+
|
|
180
|
+
// A display-only result surface tagged with the execution moment.
|
|
181
|
+
await surfaceProxyResolver(ctx, "ui_show", {
|
|
182
|
+
surface_type: "card",
|
|
183
|
+
title: "Inbox cleaned",
|
|
184
|
+
data: { text: "Archived 1,240 emails" },
|
|
185
|
+
activation_moment: "first_wow_executed",
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
// Recorded immediately on render — no handleSurfaceAction commit needed.
|
|
189
|
+
let rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
190
|
+
expect(rows).toHaveLength(1);
|
|
191
|
+
expect(rows[0]!.stepName).toBe("activation_first_wow_executed");
|
|
192
|
+
expect(rows[0]!.stepIndex).toBe(4);
|
|
193
|
+
|
|
194
|
+
// If the card later receives a commit (e.g. it carried an action), it must
|
|
195
|
+
// NOT double-emit — a show-timing tag is never stored for the commit path.
|
|
196
|
+
const showMessage = sent.find(
|
|
197
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
198
|
+
) as UiSurfaceShow;
|
|
199
|
+
await handleSurfaceAction(ctx, showMessage.surfaceId, "expand", {});
|
|
200
|
+
rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
201
|
+
expect(rows).toHaveLength(1);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
test("first_wow_executed in an UNMARKED session writes no row at show time", async () => {
|
|
205
|
+
const sent: ServerMessage[] = [];
|
|
206
|
+
const ctx = makeContext("conv-wow-unmarked", sent);
|
|
207
|
+
await surfaceProxyResolver(ctx, "ui_show", {
|
|
208
|
+
surface_type: "card",
|
|
209
|
+
title: "Result",
|
|
210
|
+
data: { text: "x" },
|
|
211
|
+
activation_moment: "first_wow_executed",
|
|
212
|
+
});
|
|
213
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test("does not forward the daemon-only tag to the client", async () => {
|
|
217
|
+
markActivationSession("conv-marked-2");
|
|
218
|
+
const sent: ServerMessage[] = [];
|
|
219
|
+
const ctx = makeContext("conv-marked-2", sent);
|
|
220
|
+
await showTaggedChoice(ctx, sent, "moment_2");
|
|
221
|
+
|
|
222
|
+
const showMessage = sent.find(
|
|
223
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
224
|
+
) as UiSurfaceShow;
|
|
225
|
+
expect(
|
|
226
|
+
(showMessage as unknown as Record<string, unknown>).activation_moment,
|
|
227
|
+
).toBeUndefined();
|
|
228
|
+
expect(JSON.stringify(showMessage)).not.toContain("moment_2");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
test("untagged surface writes no row", async () => {
|
|
232
|
+
markActivationSession("conv-untagged");
|
|
233
|
+
const sent: ServerMessage[] = [];
|
|
234
|
+
const ctx = makeContext("conv-untagged", sent);
|
|
235
|
+
const surfaceId = await showTaggedChoice(ctx, sent, undefined);
|
|
236
|
+
|
|
237
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
238
|
+
choiceId: "inbox",
|
|
239
|
+
selectedIds: ["inbox"],
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("tagged surface in an UNMARKED session writes no row", async () => {
|
|
246
|
+
const sent: ServerMessage[] = [];
|
|
247
|
+
const ctx = makeContext("conv-not-rail", sent);
|
|
248
|
+
const surfaceId = await showTaggedChoice(ctx, sent, "moment_2");
|
|
249
|
+
|
|
250
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
251
|
+
choiceId: "inbox",
|
|
252
|
+
selectedIds: ["inbox"],
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
test("an invalid activation_moment token is ignored (no row)", async () => {
|
|
259
|
+
markActivationSession("conv-bad-tag");
|
|
260
|
+
const sent: ServerMessage[] = [];
|
|
261
|
+
const ctx = makeContext("conv-bad-tag", sent);
|
|
262
|
+
const surfaceId = await showTaggedChoice(ctx, sent, "bogus_moment");
|
|
263
|
+
|
|
264
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
265
|
+
choiceId: "inbox",
|
|
266
|
+
selectedIds: ["inbox"],
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("FIX 1: launch_conversation commit on a tagged surface records exactly one row", async () => {
|
|
273
|
+
markActivationSession("conv-launch");
|
|
274
|
+
const sent: ServerMessage[] = [];
|
|
275
|
+
const ctx = makeContext("conv-launch", sent);
|
|
276
|
+
|
|
277
|
+
// A launcher card tagged with a commit-timing moment. The committed action
|
|
278
|
+
// carries `_action: launch_conversation`, so handleSurfaceAction takes the
|
|
279
|
+
// inline-launch branch (which previously returned before the emit).
|
|
280
|
+
await surfaceProxyResolver(ctx, "ui_show", {
|
|
281
|
+
surface_type: "card",
|
|
282
|
+
title: "Start something",
|
|
283
|
+
data: { text: "Kick off a draft" },
|
|
284
|
+
actions: [{ id: "go", label: "Go", style: "primary" }],
|
|
285
|
+
activation_moment: "moment_1",
|
|
286
|
+
});
|
|
287
|
+
const showMessage = sent.find(
|
|
288
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
289
|
+
) as UiSurfaceShow;
|
|
290
|
+
const surfaceId = showMessage.surfaceId;
|
|
291
|
+
|
|
292
|
+
await handleSurfaceAction(ctx, surfaceId, "go", {
|
|
293
|
+
_action: "launch_conversation",
|
|
294
|
+
title: "Draft",
|
|
295
|
+
seedPrompt: "Write a draft",
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
299
|
+
expect(rows).toHaveLength(1);
|
|
300
|
+
expect(rows[0]!.stepName).toBe("activation_moment_1_complete");
|
|
301
|
+
expect(rows[0]!.sessionId).toBe("conv-launch");
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test("FIX 2: commit-timing tag survives a surfaceState restore from history", async () => {
|
|
305
|
+
markActivationSession("conv-restore");
|
|
306
|
+
const sent: ServerMessage[] = [];
|
|
307
|
+
const ctx = makeContext("conv-restore", sent);
|
|
308
|
+
const surfaceId = await showTaggedChoice(ctx, sent, "moment_2");
|
|
309
|
+
|
|
310
|
+
// The daemon-only tag must NOT leak to the client.
|
|
311
|
+
const showMessage = sent.find(
|
|
312
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
313
|
+
) as UiSurfaceShow;
|
|
314
|
+
expect(
|
|
315
|
+
(showMessage as unknown as Record<string, unknown>).activation_moment,
|
|
316
|
+
).toBeUndefined();
|
|
317
|
+
expect(JSON.stringify(showMessage)).not.toContain("moment_2");
|
|
318
|
+
|
|
319
|
+
// Persist the surface into a history block exactly as the agent loop does
|
|
320
|
+
// (conversation-agent-loop-handlers): copy the activationMoment through.
|
|
321
|
+
const persisted = ctx.currentTurnSurfaces.map((s) => ({
|
|
322
|
+
type: "ui_surface" as const,
|
|
323
|
+
surfaceId: s.surfaceId,
|
|
324
|
+
surfaceType: s.surfaceType,
|
|
325
|
+
title: s.title,
|
|
326
|
+
data: s.data,
|
|
327
|
+
actions: s.actions,
|
|
328
|
+
...(s.activationMoment ? { activationMoment: s.activationMoment } : {}),
|
|
329
|
+
}));
|
|
330
|
+
expect(persisted[0]!.activationMoment).toBe("moment_2");
|
|
331
|
+
|
|
332
|
+
// Simulate a reload: drop the in-memory surfaceState, then rebuild it from
|
|
333
|
+
// the persisted history block the same way restoreSurfaceStateFromHistory
|
|
334
|
+
// does (including rehydrating the daemon-only tag).
|
|
335
|
+
ctx.surfaceState.clear();
|
|
336
|
+
for (const b of persisted) {
|
|
337
|
+
ctx.surfaceState.set(b.surfaceId, {
|
|
338
|
+
surfaceType: b.surfaceType,
|
|
339
|
+
data: b.data,
|
|
340
|
+
title: b.title,
|
|
341
|
+
actions: b.actions,
|
|
342
|
+
...(b.activationMoment ? { activationMoment: b.activationMoment } : {}),
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// A commit after restore still records exactly one row.
|
|
347
|
+
await handleSurfaceAction(ctx, surfaceId, "inbox", {
|
|
348
|
+
choiceId: "inbox",
|
|
349
|
+
selectedIds: ["inbox"],
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
353
|
+
expect(rows).toHaveLength(1);
|
|
354
|
+
expect(rows[0]!.stepName).toBe("activation_moment_2_complete");
|
|
355
|
+
expect(rows[0]!.sessionId).toBe("conv-restore");
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
test("intermediate selection_changed does NOT emit; the terminal commit does", async () => {
|
|
359
|
+
markActivationSession("conv-table");
|
|
360
|
+
const sent: ServerMessage[] = [];
|
|
361
|
+
const ctx = makeContext("conv-table", sent);
|
|
362
|
+
|
|
363
|
+
// A table surface stays pending across selection_changed (non-terminal).
|
|
364
|
+
await surfaceProxyResolver(ctx, "ui_show", {
|
|
365
|
+
surface_type: "table",
|
|
366
|
+
title: "Tasks",
|
|
367
|
+
data: {
|
|
368
|
+
columns: [{ id: "name", label: "Name" }],
|
|
369
|
+
rows: [
|
|
370
|
+
{ id: "r1", cells: { name: "Task 1" } },
|
|
371
|
+
{ id: "r2", cells: { name: "Task 2" } },
|
|
372
|
+
],
|
|
373
|
+
selectionMode: "multiple",
|
|
374
|
+
},
|
|
375
|
+
actions: [{ id: "run", label: "Run", style: "primary" }],
|
|
376
|
+
activation_moment: "moment_3",
|
|
377
|
+
});
|
|
378
|
+
const showMessage = sent.find(
|
|
379
|
+
(msg): msg is UiSurfaceShow => msg.type === "ui_surface_show",
|
|
380
|
+
) as UiSurfaceShow;
|
|
381
|
+
const surfaceId = showMessage.surfaceId;
|
|
382
|
+
|
|
383
|
+
// Intermediate, non-terminal — must NOT emit.
|
|
384
|
+
await handleSurfaceAction(ctx, surfaceId, "selection_changed", {
|
|
385
|
+
selectedIds: ["r1"],
|
|
386
|
+
});
|
|
387
|
+
expect(queryUnreportedOnboardingEvents(0, undefined, 10)).toHaveLength(0);
|
|
388
|
+
|
|
389
|
+
// Terminal commit — emits exactly once.
|
|
390
|
+
await handleSurfaceAction(ctx, surfaceId, "run", { selectedIds: ["r1"] });
|
|
391
|
+
const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
|
|
392
|
+
expect(rows).toHaveLength(1);
|
|
393
|
+
expect(rows[0]!.stepName).toBe("activation_moment_3_complete");
|
|
394
|
+
});
|
|
395
|
+
});
|
|
@@ -73,6 +73,7 @@ function buildMockContext(
|
|
|
73
73
|
proxy: InstanceType<typeof HostAppControlProxy> | undefined,
|
|
74
74
|
) => void,
|
|
75
75
|
trustGuardianPrincipalId: string | null = DEFAULT_PRINCIPAL,
|
|
76
|
+
actorPrincipalId: string | null = trustGuardianPrincipalId,
|
|
76
77
|
): SurfaceConversationContext {
|
|
77
78
|
return {
|
|
78
79
|
conversationId,
|
|
@@ -84,6 +85,16 @@ function buildMockContext(
|
|
|
84
85
|
guardianPrincipalId: trustGuardianPrincipalId,
|
|
85
86
|
}
|
|
86
87
|
: undefined,
|
|
88
|
+
authContext:
|
|
89
|
+
actorPrincipalId != null
|
|
90
|
+
? ({ actorPrincipalId } as SurfaceConversationContext["authContext"])
|
|
91
|
+
: undefined,
|
|
92
|
+
currentTurnAuthContext:
|
|
93
|
+
actorPrincipalId != null
|
|
94
|
+
? ({
|
|
95
|
+
actorPrincipalId,
|
|
96
|
+
} as SurfaceConversationContext["currentTurnAuthContext"])
|
|
97
|
+
: undefined,
|
|
87
98
|
traceEmitter: { emit: () => {} },
|
|
88
99
|
sendToClient: () => {},
|
|
89
100
|
pendingSurfaceActions: new Map(),
|
|
@@ -217,6 +228,39 @@ describe("surfaceProxyResolver — app-control tool routing", () => {
|
|
|
217
228
|
proxy.dispose();
|
|
218
229
|
});
|
|
219
230
|
|
|
231
|
+
test("app_control_observe rejects owner client when current requester is a trusted contact", async () => {
|
|
232
|
+
mockHubClients = [
|
|
233
|
+
{
|
|
234
|
+
clientId: "owner-client",
|
|
235
|
+
capabilities: ["host_app_control"],
|
|
236
|
+
actorPrincipalId: "owner-user",
|
|
237
|
+
},
|
|
238
|
+
];
|
|
239
|
+
const proxy = new HostAppControlProxy("conv-1");
|
|
240
|
+
const ctx = buildMockContext(
|
|
241
|
+
proxy,
|
|
242
|
+
"conv-1",
|
|
243
|
+
undefined,
|
|
244
|
+
"owner-user",
|
|
245
|
+
"trusted-contact-user",
|
|
246
|
+
);
|
|
247
|
+
_setActiveAppControlSession({
|
|
248
|
+
conversationId: "conv-1",
|
|
249
|
+
app: "com.example.editor",
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
const result = await surfaceProxyResolver(ctx, "app_control_observe", {
|
|
253
|
+
tool: "observe",
|
|
254
|
+
app: "com.example.editor",
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
expect(result.isError).toBe(true);
|
|
258
|
+
expect(result.content).toContain("current actor");
|
|
259
|
+
expect(sentMessages).toHaveLength(0);
|
|
260
|
+
|
|
261
|
+
proxy.dispose();
|
|
262
|
+
});
|
|
263
|
+
|
|
220
264
|
test("app_control_observe rejects when only another actor has a capable client", async () => {
|
|
221
265
|
mockHubClients = [
|
|
222
266
|
{
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { describe, expect, test } from "bun:test";
|
|
2
2
|
|
|
3
|
+
import { findLastUndoableUserMessageIndex } from "../daemon/conversation.js";
|
|
3
4
|
import {
|
|
4
5
|
CONTEXT_SUMMARY_MARKER,
|
|
5
6
|
createContextSummaryMessage,
|
|
6
|
-
} from "../
|
|
7
|
-
import { findLastUndoableUserMessageIndex } from "../daemon/conversation.js";
|
|
7
|
+
} from "../plugins/defaults/compaction/window-manager.js";
|
|
8
8
|
import type { Message } from "../providers/types.js";
|
|
9
9
|
|
|
10
10
|
function textMessage(role: "user" | "assistant", text: string): Message {
|
|
@@ -137,7 +137,7 @@ mock.module("../memory/retrieval-budget.js", () => ({
|
|
|
137
137
|
computeRecallBudget: () => 0,
|
|
138
138
|
}));
|
|
139
139
|
|
|
140
|
-
mock.module("../
|
|
140
|
+
mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
|
|
141
141
|
ContextWindowManager: class {
|
|
142
142
|
constructor() {}
|
|
143
143
|
updateConfig() {}
|
|
@@ -181,7 +181,7 @@ mock.module("../memory/query-builder.js", () => ({
|
|
|
181
181
|
mock.module("../memory/retrieval-budget.js", () => ({
|
|
182
182
|
computeRecallBudget: () => 0,
|
|
183
183
|
}));
|
|
184
|
-
mock.module("../
|
|
184
|
+
mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
|
|
185
185
|
ContextWindowManager: class {
|
|
186
186
|
updateConfig() {}
|
|
187
187
|
shouldCompact() {
|
|
@@ -178,7 +178,7 @@ mock.module("../memory/query-builder.js", () => ({
|
|
|
178
178
|
mock.module("../memory/retrieval-budget.js", () => ({
|
|
179
179
|
computeRecallBudget: () => 0,
|
|
180
180
|
}));
|
|
181
|
-
mock.module("../
|
|
181
|
+
mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
|
|
182
182
|
ContextWindowManager: class {
|
|
183
183
|
updateConfig() {}
|
|
184
184
|
shouldCompact() {
|
|
@@ -69,6 +69,7 @@ type SurfaceConversationContext =
|
|
|
69
69
|
function buildMockContext(
|
|
70
70
|
hostCuProxy?: InstanceType<typeof HostCuProxy>,
|
|
71
71
|
trustGuardianPrincipalId: string | null = DEFAULT_PRINCIPAL,
|
|
72
|
+
actorPrincipalId: string | null = trustGuardianPrincipalId,
|
|
72
73
|
): SurfaceConversationContext {
|
|
73
74
|
return {
|
|
74
75
|
conversationId: "test-session",
|
|
@@ -80,6 +81,16 @@ function buildMockContext(
|
|
|
80
81
|
guardianPrincipalId: trustGuardianPrincipalId,
|
|
81
82
|
}
|
|
82
83
|
: undefined,
|
|
84
|
+
authContext:
|
|
85
|
+
actorPrincipalId != null
|
|
86
|
+
? ({ actorPrincipalId } as SurfaceConversationContext["authContext"])
|
|
87
|
+
: undefined,
|
|
88
|
+
currentTurnAuthContext:
|
|
89
|
+
actorPrincipalId != null
|
|
90
|
+
? ({
|
|
91
|
+
actorPrincipalId,
|
|
92
|
+
} as SurfaceConversationContext["currentTurnAuthContext"])
|
|
93
|
+
: undefined,
|
|
83
94
|
traceEmitter: { emit: () => {} },
|
|
84
95
|
sendToClient: () => {},
|
|
85
96
|
pendingSurfaceActions: new Map(),
|
|
@@ -665,6 +676,31 @@ describe("surfaceProxyResolver — CU tool routing", () => {
|
|
|
665
676
|
expect(sentMessages).toHaveLength(0);
|
|
666
677
|
});
|
|
667
678
|
|
|
679
|
+
test("rejects untargeted CU dispatch to owner client when current requester is a trusted contact", async () => {
|
|
680
|
+
sentMessages.length = 0;
|
|
681
|
+
mockHasClient = true;
|
|
682
|
+
mockCuClients = [
|
|
683
|
+
{
|
|
684
|
+
clientId: "owner-cu-client",
|
|
685
|
+
capabilities: ["host_cu"],
|
|
686
|
+
actorPrincipalId: "owner-user",
|
|
687
|
+
},
|
|
688
|
+
];
|
|
689
|
+
proxy = new HostCuProxy();
|
|
690
|
+
const ctx = buildMockContext(proxy, "owner-user", "trusted-contact-user");
|
|
691
|
+
|
|
692
|
+
const result = await surfaceProxyResolver(ctx, "computer_use_click", {
|
|
693
|
+
element_id: 1,
|
|
694
|
+
reasoning: "click",
|
|
695
|
+
});
|
|
696
|
+
|
|
697
|
+
expect(result.isError).toBe(true);
|
|
698
|
+
expect(result.content).toContain("current actor");
|
|
699
|
+
expect(proxy.stepCount).toBe(0);
|
|
700
|
+
expect(proxy.actionHistory).toHaveLength(0);
|
|
701
|
+
expect(sentMessages).toHaveLength(0);
|
|
702
|
+
});
|
|
703
|
+
|
|
668
704
|
test("auto-resolves to the unique same-user CU client when cross-user clients are present", async () => {
|
|
669
705
|
// Regression: previously the dispatch counted only same-user clients
|
|
670
706
|
// for the multi-client guard, so 1 same-user + 1 cross-user passed the
|
|
@@ -40,6 +40,8 @@ const noopLogger: PluginLogger = {
|
|
|
40
40
|
function makeCtx(messages: Message[]): UserPromptSubmitContext {
|
|
41
41
|
return {
|
|
42
42
|
conversationId: "conv-test",
|
|
43
|
+
userMessageId: "msg-test",
|
|
44
|
+
requestId: "req-test",
|
|
43
45
|
prompt: "",
|
|
44
46
|
originalMessages: messages,
|
|
45
47
|
latestMessages: messages,
|
|
@@ -36,6 +36,7 @@ const applyRuntimeInjectionsMock = mock(async (messages: unknown) => ({
|
|
|
36
36
|
}));
|
|
37
37
|
mock.module("../daemon/conversation-runtime-assembly.js", () => ({
|
|
38
38
|
applyRuntimeInjections: applyRuntimeInjectionsMock,
|
|
39
|
+
resolveTurnInboundActorContext: () => null,
|
|
39
40
|
}));
|
|
40
41
|
|
|
41
42
|
// The hook self-resolves the live conversation and its trust class; both are
|
|
@@ -146,10 +147,8 @@ function makeHookCtx(
|
|
|
146
147
|
} as unknown as MemoryRetrievalHookContext["logger"],
|
|
147
148
|
latestMessages: [],
|
|
148
149
|
requestId: "req-test",
|
|
149
|
-
mode: "full",
|
|
150
150
|
isNonInteractive: false,
|
|
151
151
|
modelProfile: null,
|
|
152
|
-
actorContext: null,
|
|
153
152
|
...overrides,
|
|
154
153
|
};
|
|
155
154
|
}
|