@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.4

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.
Files changed (196) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/openapi.yaml +16 -115
  3. package/package.json +1 -1
  4. package/src/__tests__/activation-early-marking.test.ts +120 -0
  5. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  6. package/src/__tests__/anthropic-provider.test.ts +23 -8
  7. package/src/__tests__/approval-cascade.test.ts +1 -1
  8. package/src/__tests__/compaction-direct.test.ts +32 -18
  9. package/src/__tests__/compaction-events.test.ts +2 -2
  10. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  11. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  12. package/src/__tests__/context-window-manager-compact-retry.test.ts +121 -15
  13. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  14. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  15. package/src/__tests__/conversation-error.test.ts +15 -1
  16. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  17. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  18. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  19. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  20. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  21. package/src/__tests__/conversation-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  23. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  25. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  28. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +73 -4
  29. package/src/__tests__/conversation-undo.test.ts +2 -2
  30. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  31. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  32. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  33. package/src/__tests__/credential-security-invariants.test.ts +1 -0
  34. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  35. package/src/__tests__/history-repair-hook.test.ts +2 -0
  36. package/src/__tests__/llm-resolver.test.ts +73 -0
  37. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  38. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  39. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  40. package/src/__tests__/plugin-external-api.test.ts +68 -0
  41. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  42. package/src/__tests__/published-app-updater.test.ts +138 -0
  43. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  44. package/src/__tests__/title-generate-hook.test.ts +2 -0
  45. package/src/__tests__/web-fetch.test.ts +45 -0
  46. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  47. package/src/acp/resolve-agent.test.ts +0 -56
  48. package/src/acp/resolve-agent.ts +10 -38
  49. package/src/agent/loop.ts +13 -27
  50. package/src/api/responses/memory-v3-selection-log.ts +19 -10
  51. package/src/cli/commands/__tests__/memory-v3.test.ts +191 -210
  52. package/src/cli/commands/memory-v3.ts +57 -199
  53. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  54. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  55. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  56. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  57. package/src/cli/lib/install-from-github.ts +258 -41
  58. package/src/cli/lib/plugin-details.ts +20 -13
  59. package/src/cli/lib/plugin-marketplace.ts +23 -5
  60. package/src/cli/lib/search-plugins.ts +14 -8
  61. package/src/config/acp-defaults.ts +3 -3
  62. package/src/config/acp-schema.ts +1 -7
  63. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  64. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  65. package/src/config/call-site-defaults.ts +0 -1
  66. package/src/config/feature-flag-registry.json +3 -18
  67. package/src/config/llm-resolver.ts +39 -7
  68. package/src/config/schemas/__tests__/memory-v3.test.ts +25 -9
  69. package/src/config/schemas/call-site-catalog.ts +0 -7
  70. package/src/config/schemas/llm.ts +17 -1
  71. package/src/config/schemas/memory-v3.ts +58 -8
  72. package/src/config/seed-inference-profiles.ts +18 -0
  73. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  74. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  75. package/src/daemon/conversation-agent-loop.ts +87 -95
  76. package/src/daemon/conversation-error.ts +31 -4
  77. package/src/daemon/conversation-history.ts +1 -1
  78. package/src/daemon/conversation-media-retry.ts +19 -6
  79. package/src/daemon/conversation-messaging.ts +17 -0
  80. package/src/daemon/conversation-process.ts +14 -5
  81. package/src/daemon/conversation-queue-manager.ts +8 -0
  82. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  83. package/src/daemon/conversation-surfaces.ts +141 -3
  84. package/src/daemon/conversation.ts +48 -13
  85. package/src/daemon/external-plugins-bootstrap.ts +8 -3
  86. package/src/daemon/persist-unsendable-image.ts +62 -25
  87. package/src/daemon/process-message.ts +1 -1
  88. package/src/daemon/tool-side-effects.ts +15 -0
  89. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  90. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  91. package/src/memory/activation-session-store.ts +43 -0
  92. package/src/memory/db-init.ts +4 -0
  93. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  94. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  95. package/src/memory/migrations/index.ts +2 -0
  96. package/src/memory/onboarding-events-store.ts +66 -18
  97. package/src/memory/schema/infrastructure.ts +13 -0
  98. package/src/memory/v2/__tests__/consolidation-job.test.ts +4 -97
  99. package/src/memory/v2/__tests__/page-store.test.ts +22 -0
  100. package/src/memory/v2/consolidation-job.ts +2 -72
  101. package/src/memory/v2/types.ts +5 -0
  102. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  103. package/src/notifications/adapters/telegram.ts +7 -1
  104. package/src/plugin-api/constants.ts +2 -2
  105. package/src/plugin-api/index.ts +2 -2
  106. package/src/plugin-api/types.ts +19 -5
  107. package/src/plugins/defaults/compaction/compact.ts +24 -15
  108. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  109. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  110. package/src/{context → plugins/defaults/compaction}/window-manager.ts +68 -12
  111. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  112. package/src/plugins/defaults/memory-v3-shadow/__tests__/capabilities.test.ts +19 -66
  113. package/src/plugins/defaults/memory-v3-shadow/__tests__/dense.test.ts +181 -0
  114. package/src/plugins/defaults/memory-v3-shadow/__tests__/edge.test.ts +247 -0
  115. package/src/plugins/defaults/memory-v3-shadow/__tests__/live-integration.test.ts +139 -129
  116. package/src/plugins/defaults/memory-v3-shadow/__tests__/maintain-job.test.ts +332 -164
  117. package/src/plugins/defaults/memory-v3-shadow/__tests__/orchestrate.test.ts +516 -293
  118. package/src/plugins/defaults/memory-v3-shadow/__tests__/pool-select.test.ts +306 -0
  119. package/src/plugins/defaults/memory-v3-shadow/__tests__/render-injection.test.ts +51 -21
  120. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-dense-store.test.ts +402 -0
  121. package/src/plugins/defaults/memory-v3-shadow/__tests__/section-needle.test.ts +135 -0
  122. package/src/plugins/defaults/memory-v3-shadow/__tests__/sections.test.ts +125 -0
  123. package/src/plugins/defaults/memory-v3-shadow/__tests__/selection-log-store.test.ts +66 -11
  124. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-integration.test.ts +446 -0
  125. package/src/plugins/defaults/memory-v3-shadow/__tests__/shadow-plugin.test.ts +271 -110
  126. package/src/plugins/defaults/memory-v3-shadow/__tests__/types.test.ts +0 -22
  127. package/src/plugins/defaults/memory-v3-shadow/capabilities.ts +26 -62
  128. package/src/plugins/defaults/memory-v3-shadow/dense.ts +97 -0
  129. package/src/plugins/defaults/memory-v3-shadow/edge.ts +252 -0
  130. package/src/plugins/defaults/memory-v3-shadow/injector.ts +3 -2
  131. package/src/plugins/defaults/memory-v3-shadow/maintain-job.ts +415 -181
  132. package/src/plugins/defaults/memory-v3-shadow/orchestrate.ts +196 -56
  133. package/src/plugins/defaults/memory-v3-shadow/page-content.ts +39 -2
  134. package/src/plugins/defaults/memory-v3-shadow/pool-select.ts +204 -0
  135. package/src/plugins/defaults/memory-v3-shadow/render-injection.ts +15 -7
  136. package/src/plugins/defaults/memory-v3-shadow/section-dense-store.ts +236 -0
  137. package/src/plugins/defaults/memory-v3-shadow/section-needle.ts +200 -0
  138. package/src/plugins/defaults/memory-v3-shadow/sections.ts +115 -0
  139. package/src/plugins/defaults/memory-v3-shadow/selection-log-store.ts +75 -3
  140. package/src/plugins/defaults/memory-v3-shadow/shadow-plugin.ts +111 -78
  141. package/src/plugins/defaults/memory-v3-shadow/types.ts +52 -19
  142. package/src/plugins/defaults/memory-v3-shadow/working-set.ts +4 -1
  143. package/src/plugins/external-api.ts +114 -0
  144. package/src/prompts/system-prompt.ts +61 -10
  145. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  146. package/src/providers/anthropic/client.ts +9 -10
  147. package/src/providers/inference/kimi-cjk-token-ids.ts +493 -0
  148. package/src/providers/inference/logit-bias.ts +55 -0
  149. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  150. package/src/providers/openai/chat-completions-provider.ts +44 -1
  151. package/src/providers/retry.ts +22 -0
  152. package/src/providers/types.ts +6 -0
  153. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  154. package/src/runtime/routes/acp-routes.test.ts +3 -20
  155. package/src/runtime/routes/app-management-routes.ts +3 -0
  156. package/src/runtime/routes/conversation-routes.ts +2 -0
  157. package/src/runtime/routes/memory-v3-routes.ts +64 -314
  158. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  159. package/src/runtime/routes/stt-routes.ts +45 -12
  160. package/src/runtime/routes/workspace-routes.ts +50 -15
  161. package/src/services/published-app-updater.ts +30 -8
  162. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  163. package/src/telemetry/activation-funnel.ts +167 -0
  164. package/src/telemetry/types.ts +13 -0
  165. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  166. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  167. package/src/tools/acp/list-agents.test.ts +2 -18
  168. package/src/tools/acp/list-agents.ts +3 -15
  169. package/src/tools/acp/spawn.test.ts +0 -10
  170. package/src/tools/browser/browser-execution.ts +12 -2
  171. package/src/tools/network/web-fetch.ts +65 -24
  172. package/src/tools/skills/load.ts +1 -1
  173. package/src/tools/ui-surface/definitions.ts +7 -0
  174. package/src/acp/feature-gate.test.ts +0 -48
  175. package/src/acp/feature-gate.ts +0 -34
  176. package/src/plugins/defaults/memory-v3-shadow/__tests__/assign.test.ts +0 -242
  177. package/src/plugins/defaults/memory-v3-shadow/__tests__/core.test.ts +0 -39
  178. package/src/plugins/defaults/memory-v3-shadow/__tests__/health.test.ts +0 -219
  179. package/src/plugins/defaults/memory-v3-shadow/__tests__/needle.test.ts +0 -107
  180. package/src/plugins/defaults/memory-v3-shadow/__tests__/provider-blocks.test.ts +0 -13
  181. package/src/plugins/defaults/memory-v3-shadow/__tests__/reconcile.test.ts +0 -274
  182. package/src/plugins/defaults/memory-v3-shadow/__tests__/router.test.ts +0 -337
  183. package/src/plugins/defaults/memory-v3-shadow/__tests__/selector.test.ts +0 -470
  184. package/src/plugins/defaults/memory-v3-shadow/__tests__/snapshot.test.ts +0 -168
  185. package/src/plugins/defaults/memory-v3-shadow/__tests__/tree.test.ts +0 -192
  186. package/src/plugins/defaults/memory-v3-shadow/assign.ts +0 -272
  187. package/src/plugins/defaults/memory-v3-shadow/core.ts +0 -26
  188. package/src/plugins/defaults/memory-v3-shadow/health.ts +0 -0
  189. package/src/plugins/defaults/memory-v3-shadow/needle.ts +0 -115
  190. package/src/plugins/defaults/memory-v3-shadow/provider-blocks.ts +0 -26
  191. package/src/plugins/defaults/memory-v3-shadow/reconcile.ts +0 -527
  192. package/src/plugins/defaults/memory-v3-shadow/router.ts +0 -190
  193. package/src/plugins/defaults/memory-v3-shadow/selector.ts +0 -226
  194. package/src/plugins/defaults/memory-v3-shadow/snapshot.ts +0 -209
  195. package/src/plugins/defaults/memory-v3-shadow/tree.ts +0 -174
  196. package/src/util/map-limit.ts +0 -27
@@ -221,7 +221,7 @@ mock.module("../memory/retriever.js", () => ({
221
221
  let maybeCompactCalls: Array<{ force: boolean }> = [];
222
222
  let forceCompactionEnabled = false;
223
223
 
224
- mock.module("../context/window-manager.js", () => ({
224
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
225
225
  ContextWindowManager: class {
226
226
  constructor() {}
227
227
  updateConfig() {}
@@ -262,7 +262,7 @@ mock.module("../memory/retriever.js", () => ({
262
262
  injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
263
263
  }));
264
264
 
265
- mock.module("../context/window-manager.js", () => ({
265
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
266
266
  ContextWindowManager: class {
267
267
  constructor() {}
268
268
  updateConfig() {}
@@ -86,12 +86,14 @@ import {
86
86
  loadSlackChronologicalContext,
87
87
  loadSlackChronologicalMessages,
88
88
  resolveChannelCapabilities,
89
+ resolveTurnInboundActorContext,
89
90
  stripChannelCapabilityContext,
90
91
  stripInjectionsForCompaction,
91
92
  stripNowScratchpad,
92
93
  } from "../daemon/conversation-runtime-assembly.js";
93
94
  import type { SurfaceData, SurfaceType } from "../daemon/message-protocol.js";
94
95
  import { buildPkbReminder } from "../daemon/pkb-reminder-builder.js";
96
+ import type { TrustContext } from "../daemon/trust-context.js";
95
97
  import type { MessageRow } from "../memory/conversation-crud.js";
96
98
  import { getDb } from "../memory/db-connection.js";
97
99
  import { initializeDb } from "../memory/db-init.js";
@@ -1438,6 +1440,75 @@ describe("applyRuntimeInjections with nowScratchpad", () => {
1438
1440
  });
1439
1441
  });
1440
1442
 
1443
+ // ---------------------------------------------------------------------------
1444
+ // resolveTurnInboundActorContext
1445
+ // ---------------------------------------------------------------------------
1446
+
1447
+ describe("resolveTurnInboundActorContext", () => {
1448
+ /**
1449
+ * No trust context means there is no inbound actor to ground the turn on, so
1450
+ * the actor section is suppressed.
1451
+ */
1452
+ test("returns null when there is no trust context", () => {
1453
+ // GIVEN a turn with no trust context
1454
+ // WHEN the inbound actor context is resolved
1455
+ const actorContext = resolveTurnInboundActorContext(undefined, "asst-1");
1456
+
1457
+ // THEN there is no actor context to inject
1458
+ expect(actorContext).toBeNull();
1459
+ });
1460
+
1461
+ /**
1462
+ * Guardian (owner) turns suppress the actor section — the unified turn context
1463
+ * only renders actor fields for non-guardian actors.
1464
+ */
1465
+ test("returns null on guardian turns", () => {
1466
+ // GIVEN a guardian trust context (no requester chat id, so the direct
1467
+ // projection branch is taken)
1468
+ const trustContext: TrustContext = {
1469
+ sourceChannel: "vellum",
1470
+ trustClass: "guardian",
1471
+ requesterDisplayName: "Alice",
1472
+ };
1473
+
1474
+ // WHEN the inbound actor context is resolved
1475
+ const actorContext = resolveTurnInboundActorContext(trustContext, "asst-1");
1476
+
1477
+ // THEN the actor section is suppressed for the owner
1478
+ expect(actorContext).toBeNull();
1479
+ });
1480
+
1481
+ /**
1482
+ * A non-guardian trust context without the identity fields needed for the
1483
+ * unified actor-trust lookup is projected directly into the actor context.
1484
+ */
1485
+ test("projects a non-guardian trust context directly", () => {
1486
+ // GIVEN a trusted-contact trust context lacking requester chat/user ids
1487
+ const trustContext: TrustContext = {
1488
+ sourceChannel: "telegram",
1489
+ trustClass: "trusted_contact",
1490
+ requesterIdentifier: "@bob_handle",
1491
+ requesterDisplayName: "Bob",
1492
+ requesterSenderDisplayName: "Bobby",
1493
+ };
1494
+
1495
+ // WHEN the inbound actor context is resolved
1496
+ const actorContext = resolveTurnInboundActorContext(trustContext, "asst-1");
1497
+
1498
+ // THEN the trust context is projected into the model-facing actor context
1499
+ expect(actorContext).toEqual({
1500
+ sourceChannel: "telegram",
1501
+ canonicalActorIdentity: null,
1502
+ actorIdentifier: "@bob_handle",
1503
+ actorDisplayName: "Bob",
1504
+ actorSenderDisplayName: "Bobby",
1505
+ actorMemberDisplayName: undefined,
1506
+ trustClass: "trusted_contact",
1507
+ guardianIdentity: undefined,
1508
+ });
1509
+ });
1510
+ });
1511
+
1441
1512
  // ---------------------------------------------------------------------------
1442
1513
  // buildUnifiedTurnContextBlock
1443
1514
  // ---------------------------------------------------------------------------
@@ -153,7 +153,7 @@ mock.module("../memory/retriever.js", () => ({
153
153
  injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
154
154
  }));
155
155
 
156
- mock.module("../context/window-manager.js", () => ({
156
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
157
157
  ContextWindowManager: class {
158
158
  constructor() {}
159
159
  updateConfig() {}
@@ -154,7 +154,7 @@ mock.module("../memory/retriever.js", () => ({
154
154
  injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
155
155
  }));
156
156
 
157
- mock.module("../context/window-manager.js", () => ({
157
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
158
158
  ContextWindowManager: class {
159
159
  constructor() {}
160
160
  updateConfig() {}
@@ -170,7 +170,7 @@ mock.module("../memory/retriever.js", () => ({
170
170
  injectMemoryRecallAsUserBlock: (msgs: Message[]) => msgs,
171
171
  }));
172
172
 
173
- mock.module("../context/window-manager.js", () => ({
173
+ mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
174
174
  ContextWindowManager: class {
175
175
  constructor() {}
176
176
  updateConfig() {}
@@ -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
  {