@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.
Files changed (111) hide show
  1. package/docs/activation-funnel-telemetry.md +310 -0
  2. package/package.json +1 -1
  3. package/src/__tests__/activation-early-marking.test.ts +120 -0
  4. package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
  5. package/src/__tests__/approval-cascade.test.ts +1 -1
  6. package/src/__tests__/compaction-direct.test.ts +32 -18
  7. package/src/__tests__/compaction-events.test.ts +2 -2
  8. package/src/__tests__/compaction.benchmark.test.ts +1 -1
  9. package/src/__tests__/context-overflow-reducer.test.ts +5 -5
  10. package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
  11. package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
  12. package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
  13. package/src/__tests__/conversation-error.test.ts +15 -1
  14. package/src/__tests__/conversation-history-web-search.test.ts +5 -0
  15. package/src/__tests__/conversation-media-retry.test.ts +1 -1
  16. package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
  17. package/src/__tests__/conversation-process-callsite.test.ts +1 -1
  18. package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
  19. package/src/__tests__/conversation-queue.test.ts +1 -1
  20. package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
  21. package/src/__tests__/conversation-slash-queue.test.ts +1 -1
  22. package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
  23. package/src/__tests__/conversation-speed-override.test.ts +1 -1
  24. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
  25. package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
  26. package/src/__tests__/conversation-undo.test.ts +2 -2
  27. package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
  28. package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
  29. package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
  30. package/src/__tests__/cu-unified-flow.test.ts +36 -0
  31. package/src/__tests__/history-repair-hook.test.ts +2 -0
  32. package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
  33. package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
  34. package/src/__tests__/persist-unsendable-image.test.ts +97 -1
  35. package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
  36. package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
  37. package/src/__tests__/title-generate-hook.test.ts +2 -0
  38. package/src/__tests__/web-fetch.test.ts +45 -0
  39. package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
  40. package/src/acp/resolve-agent.test.ts +0 -56
  41. package/src/acp/resolve-agent.ts +10 -38
  42. package/src/agent/loop.ts +13 -27
  43. package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
  44. package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
  45. package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
  46. package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
  47. package/src/cli/lib/install-from-github.ts +258 -41
  48. package/src/cli/lib/plugin-details.ts +20 -13
  49. package/src/cli/lib/plugin-marketplace.ts +23 -5
  50. package/src/cli/lib/search-plugins.ts +14 -8
  51. package/src/config/acp-defaults.ts +3 -3
  52. package/src/config/acp-schema.ts +1 -7
  53. package/src/config/bundled-skills/acp/SKILL.md +4 -17
  54. package/src/config/bundled-skills/acp/TOOLS.json +2 -2
  55. package/src/config/feature-flag-registry.json +3 -18
  56. package/src/context/post-turn-tool-result-truncation.ts +39 -1
  57. package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
  58. package/src/daemon/conversation-agent-loop.ts +78 -78
  59. package/src/daemon/conversation-error.ts +31 -4
  60. package/src/daemon/conversation-history.ts +1 -1
  61. package/src/daemon/conversation-media-retry.ts +19 -6
  62. package/src/daemon/conversation-messaging.ts +17 -0
  63. package/src/daemon/conversation-process.ts +14 -5
  64. package/src/daemon/conversation-queue-manager.ts +8 -0
  65. package/src/daemon/conversation-runtime-assembly.ts +37 -1
  66. package/src/daemon/conversation-surfaces.ts +141 -3
  67. package/src/daemon/conversation.ts +48 -13
  68. package/src/daemon/persist-unsendable-image.ts +62 -25
  69. package/src/daemon/process-message.ts +1 -1
  70. package/src/memory/__tests__/activation-session-store.test.ts +41 -0
  71. package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
  72. package/src/memory/activation-session-store.ts +43 -0
  73. package/src/memory/db-init.ts +4 -0
  74. package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
  75. package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
  76. package/src/memory/migrations/index.ts +2 -0
  77. package/src/memory/onboarding-events-store.ts +66 -18
  78. package/src/memory/schema/infrastructure.ts +13 -0
  79. package/src/messaging/providers/telegram-bot/api.ts +14 -5
  80. package/src/notifications/adapters/telegram.ts +7 -1
  81. package/src/plugin-api/constants.ts +2 -2
  82. package/src/plugin-api/index.ts +2 -2
  83. package/src/plugin-api/types.ts +19 -5
  84. package/src/plugins/defaults/compaction/compact.ts +24 -15
  85. package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
  86. package/src/plugins/defaults/compaction/manager-store.ts +1 -1
  87. package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
  88. package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
  89. package/src/prompts/system-prompt.ts +61 -10
  90. package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
  91. package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
  92. package/src/providers/openai/chat-completions-provider.ts +25 -0
  93. package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
  94. package/src/runtime/routes/acp-routes.test.ts +3 -20
  95. package/src/runtime/routes/conversation-routes.ts +2 -0
  96. package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
  97. package/src/runtime/routes/stt-routes.ts +45 -12
  98. package/src/runtime/routes/workspace-routes.ts +50 -15
  99. package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
  100. package/src/telemetry/activation-funnel.ts +167 -0
  101. package/src/telemetry/types.ts +13 -0
  102. package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
  103. package/src/telemetry/usage-telemetry-reporter.ts +26 -1
  104. package/src/tools/acp/list-agents.test.ts +2 -18
  105. package/src/tools/acp/list-agents.ts +3 -15
  106. package/src/tools/acp/spawn.test.ts +0 -10
  107. package/src/tools/browser/browser-execution.ts +12 -2
  108. package/src/tools/network/web-fetch.ts +65 -24
  109. package/src/tools/ui-surface/definitions.ts +7 -0
  110. package/src/acp/feature-gate.test.ts +0 -48
  111. package/src/acp/feature-gate.ts +0 -34
@@ -150,11 +150,25 @@ const STREAMING_ERROR_PATTERNS = [
150
150
  ];
151
151
 
152
152
  // User-initiated cancellation patterns — these should NOT produce conversation_error
153
- // Image-input validation patterns — Anthropic 400s with this message when an image
154
- // block exceeds the per-side pixel cap. Distinct classification matters because
155
- // retrying with the same oversized image is futile; the user needs to resize.
153
+ // Image-input validation patterns — Anthropic 400s with one of these messages
154
+ // when an image block violates a hard limit. The first matches the per-side
155
+ // pixel cap ("image dimensions exceed max allowed size"); the second matches the
156
+ // base64 payload cap ("image exceeds 5 MB maximum: 7465044 bytes > 5242880
157
+ // bytes"). Distinct classification matters because retrying with the same
158
+ // oversized image is futile — the recovery path must strip or downscale it.
156
159
  const IMAGE_DIMENSIONS_TOO_LARGE_PATTERNS = [
157
160
  /image dimensions? exceeds? max allowed size/i,
161
+ /image exceeds \d+\s*MB maximum/i,
162
+ ];
163
+
164
+ const VISION_NOT_SUPPORTED_PATTERNS = [
165
+ /no endpoints found that support image input/i,
166
+ /does not support image/i,
167
+ /doesn't support image input/i,
168
+ /image input is not supported/i,
169
+ /this model does not support vision/i,
170
+ /vision is not supported/i,
171
+ /multi-?modal.*not.*support/i,
158
172
  ];
159
173
 
160
174
  const CANCEL_PATTERNS = [/abort/i, /cancel/i];
@@ -447,11 +461,20 @@ function classifyCore(
447
461
  return {
448
462
  code: "IMAGE_TOO_LARGE",
449
463
  userMessage:
450
- "An attached image is too large for the AI provider — image dimensions must be under 8000 pixels per side. Resize the image and try again.",
464
+ "An image in this conversation was too large for the AI provider and was automatically reduced. Send your message again to continue.",
451
465
  retryable: false,
452
466
  errorCategory: "image_dimensions_too_large",
453
467
  };
454
468
  }
469
+ if (isVisionNotSupported(message)) {
470
+ return {
471
+ code: "PROVIDER_API",
472
+ userMessage:
473
+ "This model doesn't support image input. Remove the image or switch to a vision-capable model.",
474
+ retryable: false,
475
+ errorCategory: "vision_not_supported",
476
+ };
477
+ }
455
478
  // Extract the provider detail after "API error (NNN): " prefix
456
479
  const detailMatch = message.match(/API error \(\d+\):\s*(.+)/i);
457
480
  const detail = detailMatch?.[1];
@@ -481,6 +504,10 @@ function isImageDimensionsTooLarge(message: string): boolean {
481
504
  return IMAGE_DIMENSIONS_TOO_LARGE_PATTERNS.some((p) => p.test(message));
482
505
  }
483
506
 
507
+ function isVisionNotSupported(message: string): boolean {
508
+ return VISION_NOT_SUPPORTED_PATTERNS.some((p) => p.test(message));
509
+ }
510
+
484
511
  /** Check whether an error message indicates a web-search-specific ordering failure. */
485
512
  function isWebSearchOrderingError(message: string): boolean {
486
513
  return WEB_SEARCH_ORDERING_PATTERNS.some((p) => p.test(message));
@@ -1,6 +1,5 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
 
3
- import { getSummaryFromContextMessage } from "../context/window-manager.js";
4
3
  import {
5
4
  deleteLastExchange,
6
5
  deleteMessageById,
@@ -13,6 +12,7 @@ import { enqueueMemoryJob } from "../memory/jobs-store.js";
13
12
  import { relinkLlmRequestLogs } from "../memory/llm-request-log-store.js";
14
13
  import { withQdrantBreaker } from "../memory/qdrant-circuit-breaker.js";
15
14
  import { getQdrantClient } from "../memory/qdrant-client.js";
15
+ import { getSummaryFromContextMessage } from "../plugins/defaults/compaction/window-manager.js";
16
16
  import type { ContentBlock, Message } from "../providers/types.js";
17
17
  import { getLogger } from "../util/logger.js";
18
18
  import { truncate } from "../util/truncate.js";
@@ -7,7 +7,7 @@
7
7
  */
8
8
 
9
9
  import { estimateContentBlockTokens } from "../context/token-estimator.js";
10
- import { getSummaryFromContextMessage } from "../context/window-manager.js";
10
+ import { getSummaryFromContextMessage } from "../plugins/defaults/compaction/window-manager.js";
11
11
  import type { ContentBlock, Message } from "../providers/types.js";
12
12
 
13
13
  export interface StripMediaOptions {
@@ -173,15 +173,28 @@ export function estimateUnconditionalStubTokens(
173
173
  const msg = messages[msgIndex];
174
174
  for (const block of msg.content) {
175
175
  if (block.type === "image" && msgIndex !== latestUserIndex) {
176
- stubTokens += estimateContentBlockTokens(imageBlockToStub(block), options);
176
+ stubTokens += estimateContentBlockTokens(
177
+ imageBlockToStub(block),
178
+ options,
179
+ );
177
180
  } else if (block.type === "file" && msgIndex !== latestUserIndex) {
178
- stubTokens += estimateContentBlockTokens(fileBlockToStub(block), options);
179
- } else if (block.type === "tool_result" && block.contentBlocks) { // guard:allow-tool-result-only — web_search_tool_result has no contentBlocks
181
+ stubTokens += estimateContentBlockTokens(
182
+ fileBlockToStub(block),
183
+ options,
184
+ );
185
+ } else if (block.type === "tool_result" && block.contentBlocks) {
186
+ // guard:allow-tool-result-only — web_search_tool_result has no contentBlocks
180
187
  for (const cb of block.contentBlocks) {
181
188
  if (cb.type === "image") {
182
- stubTokens += estimateContentBlockTokens(imageBlockToStub(cb), options);
189
+ stubTokens += estimateContentBlockTokens(
190
+ imageBlockToStub(cb),
191
+ options,
192
+ );
183
193
  } else if (cb.type === "file") {
184
- stubTokens += estimateContentBlockTokens(fileBlockToStub(cb), options);
194
+ stubTokens += estimateContentBlockTokens(
195
+ fileBlockToStub(cb),
196
+ options,
197
+ );
185
198
  }
186
199
  }
187
200
  }
@@ -43,6 +43,7 @@ import {
43
43
  } from "../messaging/providers/slack/message-metadata.js";
44
44
  import type { SecretPrompter } from "../permissions/secret-prompter.js";
45
45
  import type { Message } from "../providers/types.js";
46
+ import type { AuthContext } from "../runtime/auth/types.js";
46
47
  import { getLogger } from "../util/logger.js";
47
48
  import type { MessageQueue } from "./conversation-queue-manager.js";
48
49
  import type { SlackInboundMessageMetadata } from "./handlers/shared.js";
@@ -193,6 +194,9 @@ export interface MessagingConversationContext {
193
194
  currentRequestId?: string;
194
195
  readonly queue: MessageQueue;
195
196
  trustContext?: TrustContext;
197
+ authContext?: AuthContext;
198
+ currentTurnAuthContext?: AuthContext;
199
+ currentTurnSourceActorPrincipalId?: string;
196
200
  getTurnChannelContext(): TurnChannelContext | null;
197
201
  getTurnInterfaceContext(): TurnInterfaceContext | null;
198
202
  }
@@ -302,6 +306,10 @@ export interface EnqueueMessageOptions {
302
306
  displayContent?: string;
303
307
  transport?: ConversationTransportMetadata;
304
308
  clientMessageId?: string;
309
+ /** JWT-verified requester principal captured for queued host-proxy routing. */
310
+ sourceActorPrincipalId?: string;
311
+ /** Auth context snapshot captured for queued turn-scoped authorization. */
312
+ authContext?: AuthContext;
305
313
  }
306
314
 
307
315
  // ── enqueueMessage ───────────────────────────────────────────────────
@@ -322,7 +330,14 @@ export function enqueueMessage(
322
330
  displayContent,
323
331
  transport,
324
332
  clientMessageId,
333
+ authContext,
325
334
  } = options;
335
+ const queuedAuthContext =
336
+ authContext ?? ctx.currentTurnAuthContext ?? ctx.authContext;
337
+ const sourceActorPrincipalId =
338
+ options.sourceActorPrincipalId ??
339
+ ctx.currentTurnSourceActorPrincipalId ??
340
+ queuedAuthContext?.actorPrincipalId;
326
341
 
327
342
  if (!ctx.isProcessing()) {
328
343
  return { queued: false, requestId };
@@ -347,6 +362,8 @@ export function enqueueMessage(
347
362
  turnChannelContext,
348
363
  turnInterfaceContext,
349
364
  isInteractive,
365
+ sourceActorPrincipalId,
366
+ authContext: queuedAuthContext,
350
367
  transport,
351
368
  displayContent,
352
369
  sentAt: Date.now(),
@@ -18,7 +18,6 @@ import {
18
18
  type TurnInterfaceContext,
19
19
  } from "../channels/types.js";
20
20
  import type { LLMCallSite } from "../config/schemas/llm.js";
21
- import type { ContextWindowResult } from "../context/window-manager.js";
22
21
  import { listPendingRequestsByConversationScope } from "../memory/canonical-guardian-store.js";
23
22
  import {
24
23
  addMessage,
@@ -28,6 +27,7 @@ import {
28
27
  } from "../memory/conversation-crud.js";
29
28
  import { extractPreferences } from "../notifications/preference-extractor.js";
30
29
  import { createPreference } from "../notifications/preferences-store.js";
30
+ import type { ContextWindowResult } from "../plugins/defaults/compaction/window-manager.js";
31
31
  import { routeGuardianReply } from "../runtime/guardian-reply-router.js";
32
32
  import { publishConversationMessagesChanged } from "../runtime/sync/resource-sync-events.js";
33
33
  import { getLogger } from "../util/logger.js";
@@ -222,6 +222,8 @@ async function buildPassthroughBatch(
222
222
  // otherwise diverge.
223
223
  if (candIf?.userMessageInterface !== headInterface?.userMessageInterface)
224
224
  break;
225
+ if (candidate.sourceActorPrincipalId !== head.sourceActorPrincipalId)
226
+ break;
225
227
  if (classifySlash(candidate.content) !== "passthrough") break;
226
228
  if (
227
229
  resolveVerificationSessionIntent(candidate.content).kind ===
@@ -422,6 +424,9 @@ async function drainSingleMessage(
422
424
  conversation.applyClientTimezoneFromTransport(next.transport);
423
425
  }
424
426
 
427
+ conversation.currentTurnAuthContext = next.authContext;
428
+ conversation.currentTurnSourceActorPrincipalId = next.sourceActorPrincipalId;
429
+
425
430
  // Re-attach and re-preactivate host-proxy skills for interactive turns.
426
431
  // The dequeue path reset `preactivatedSkillIds` above; without these
427
432
  // re-adds the relevant skill tools won't be projected to the LLM for
@@ -433,8 +438,7 @@ async function drainSingleMessage(
433
438
  const interfaceCtx =
434
439
  queuedInterfaceCtx ?? conversation.getTurnInterfaceContext();
435
440
  const sourceInterface = interfaceCtx?.userMessageInterface;
436
- const sourceActorPrincipalId =
437
- conversation.trustContext?.guardianPrincipalId;
441
+ const sourceActorPrincipalId = next.sourceActorPrincipalId;
438
442
  conversation.ensureHostProxiesForTurn(
439
443
  sourceInterface,
440
444
  sourceActorPrincipalId,
@@ -979,14 +983,16 @@ async function drainBatch(
979
983
  conversation.applyClientTimezoneFromTransport(head.transport);
980
984
  }
981
985
 
986
+ conversation.currentTurnAuthContext = head.authContext;
987
+ conversation.currentTurnSourceActorPrincipalId = head.sourceActorPrincipalId;
988
+
982
989
  // Re-attach and re-preactivate host-proxy skills for interactive turns.
983
990
  // Mirrors the single-message path exactly — sourced from `head`.
984
991
  if (head.isInteractive !== false) {
985
992
  const interfaceCtx =
986
993
  queuedInterfaceCtx ?? conversation.getTurnInterfaceContext();
987
994
  const sourceInterface = interfaceCtx?.userMessageInterface;
988
- const sourceActorPrincipalId =
989
- conversation.trustContext?.guardianPrincipalId;
995
+ const sourceActorPrincipalId = head.sourceActorPrincipalId;
990
996
  conversation.ensureHostProxiesForTurn(
991
997
  sourceInterface,
992
998
  sourceActorPrincipalId,
@@ -1380,6 +1386,9 @@ export async function processMessage(
1380
1386
  // Snapshot persona context at turn start so later tool turns can't pick up
1381
1387
  // a different actor's context if a concurrent request mutates the live fields.
1382
1388
  conversation.currentTurnTrustContext = conversation.trustContext;
1389
+ conversation.currentTurnAuthContext = conversation.authContext;
1390
+ conversation.currentTurnSourceActorPrincipalId =
1391
+ conversation.authContext?.actorPrincipalId;
1383
1392
  conversation.currentTurnChannelCapabilities =
1384
1393
  conversation.channelCapabilities;
1385
1394
  conversation.currentActiveSurfaceId = activeSurfaceId;
@@ -9,6 +9,7 @@ import type {
9
9
  TurnChannelContext,
10
10
  TurnInterfaceContext,
11
11
  } from "../channels/types.js";
12
+ import type { AuthContext } from "../runtime/auth/types.js";
12
13
  import { getLogger } from "../util/logger.js";
13
14
  import type {
14
15
  ServerMessage,
@@ -30,6 +31,10 @@ export interface QueuedMessage {
30
31
  turnInterfaceContext?: TurnInterfaceContext;
31
32
  /** When false, the turn has no interactive user and should skip clarification prompts. */
32
33
  isInteractive?: boolean;
34
+ /** Requester identity captured from the verified auth context at enqueue time. */
35
+ sourceActorPrincipalId?: string;
36
+ /** Full auth snapshot captured at enqueue time for turn-scoped authorization decisions. */
37
+ authContext?: AuthContext;
33
38
  /** Transport metadata snapshot captured at enqueue time, applied when this message becomes active. */
34
39
  transport?: ConversationTransportMetadata;
35
40
  /** Original user message text to persist to DB when recording intent stripping produced a different `content`. */
@@ -204,6 +209,9 @@ function estimateItemBytes(item: QueuedMessage): number {
204
209
  if (item.transport) {
205
210
  bytes += JSON.stringify(item.transport).length * 2;
206
211
  }
212
+ if (item.sourceActorPrincipalId) {
213
+ bytes += item.sourceActorPrincipalId.length * 2;
214
+ }
207
215
  // Small fixed overhead for metadata, pointers, etc. (not worth
208
216
  // measuring precisely — the content/attachment data dominates).
209
217
  bytes += 512;
@@ -13,7 +13,6 @@ import { isAssistantFeatureFlagEnabled } from "../config/assistant-feature-flags
13
13
  import { getConfig } from "../config/loader.js";
14
14
  import type { LLMCallSite } from "../config/schemas/llm.js";
15
15
  import { stripUserTextBlocksByPrefix } from "../context/strip-injections.js";
16
- import { createContextSummaryMessage } from "../context/window-manager.js";
17
16
  import { getDocumentsForConversation } from "../documents/document-store.js";
18
17
  import {
19
18
  getApp,
@@ -43,6 +42,7 @@ import {
43
42
  type RenderedSlackTranscriptMessage,
44
43
  renderSlackTranscriptWithProvenance,
45
44
  } from "../messaging/providers/slack/render-transcript.js";
45
+ import { createContextSummaryMessage } from "../plugins/defaults/compaction/window-manager.js";
46
46
  import { getInjectorChain } from "../plugins/defaults/memory-retrieval/injector-chain.js";
47
47
  import { MEMORY_V3_BLOCK_ID } from "../plugins/defaults/memory-v3-shadow/types.js";
48
48
  import type {
@@ -54,8 +54,10 @@ import type { ContentBlock, Message } from "../providers/types.js";
54
54
  import {
55
55
  type ActorTrustContext,
56
56
  isUntrustedTrustClass,
57
+ resolveActorTrust,
57
58
  type TrustClass,
58
59
  } from "../runtime/actor-trust-resolver.js";
60
+ import { DAEMON_INTERNAL_ASSISTANT_ID } from "../runtime/assistant-scope.js";
59
61
  import { channelStatusToMemberStatus } from "../runtime/routes/inbound-stages/acl-enforcement.js";
60
62
  import { getSubagentManager } from "../subagent/index.js";
61
63
  import type { SubagentState } from "../subagent/types.js";
@@ -174,6 +176,40 @@ export function inboundActorContextFromTrust(
174
176
  };
175
177
  }
176
178
 
179
+ /**
180
+ * Resolve the model-facing inbound actor context for a turn from the
181
+ * conversation's trust context, for the unified `<turn_context>` actor section.
182
+ *
183
+ * Returns `null` when there is no trust context and on guardian (owner) turns —
184
+ * the actor section is suppressed for the owner. When the trust context carries
185
+ * enough identity to look up member status / policy and the guardian binding,
186
+ * the unified actor-trust resolver is preferred; otherwise the context is
187
+ * projected directly. Derives purely from the passed trust context, so callers
188
+ * self-resolve it from the live conversation rather than threading it.
189
+ */
190
+ export function resolveTurnInboundActorContext(
191
+ trustContext: TrustContext | undefined,
192
+ assistantId: string | undefined,
193
+ ): InboundActorContext | null {
194
+ if (!trustContext) {
195
+ return null;
196
+ }
197
+ let resolved: InboundActorContext;
198
+ if (trustContext.requesterExternalUserId && trustContext.requesterChatId) {
199
+ const actorTrust = resolveActorTrust({
200
+ assistantId: assistantId ?? DAEMON_INTERNAL_ASSISTANT_ID,
201
+ sourceChannel: trustContext.sourceChannel,
202
+ conversationExternalId: trustContext.requesterChatId,
203
+ actorExternalId: trustContext.requesterExternalUserId,
204
+ actorDisplayName: trustContext.requesterSenderDisplayName,
205
+ });
206
+ resolved = inboundActorContextFromTrust(actorTrust);
207
+ } else {
208
+ resolved = inboundActorContextFromTrustContext(trustContext);
209
+ }
210
+ return resolved.trustClass === "guardian" ? null : resolved;
211
+ }
212
+
177
213
  /** Derive channel capabilities from source channel + interface identifiers. */
178
214
  export function resolveChannelCapabilities(
179
215
  sourceChannel?: string | null,
@@ -1,5 +1,6 @@
1
1
  import { v4 as uuid } from "uuid";
2
2
 
3
+ import { isActivationSession } from "../memory/activation-session-store.js";
3
4
  import {
4
5
  addAppConversationId,
5
6
  getApp,
@@ -14,6 +15,7 @@ import {
14
15
  getMessages,
15
16
  updateMessageContent,
16
17
  } from "../memory/conversation-crud.js";
18
+ import { recordActivationEvent } from "../memory/onboarding-events-store.js";
17
19
  import {
18
20
  assistantEventHub,
19
21
  broadcastMessage,
@@ -23,10 +25,17 @@ import {
23
25
  enforceSameActorOrErrorResult,
24
26
  pickSameUserAutoResolve,
25
27
  } from "../runtime/auth/same-actor.js";
28
+ import type { AuthContext } from "../runtime/auth/types.js";
26
29
  import type {
27
30
  InteractiveUiRequest,
28
31
  InteractiveUiResult,
29
32
  } from "../runtime/interactive-ui-types.js";
33
+ import {
34
+ activationMomentEmitsAtShow,
35
+ type ActivationMomentParam,
36
+ activationStepNameForMomentParam,
37
+ isActivationMomentParam,
38
+ } from "../telemetry/activation-funnel.js";
30
39
  import type { ToolExecutionResult } from "../tools/types.js";
31
40
  import { getLogger } from "../util/logger.js";
32
41
  import { isPlainObject } from "../util/object.js";
@@ -544,6 +553,12 @@ export interface SurfaceConversationContext {
544
553
  readonly assistantId?: string;
545
554
  /** Inherited to spawned conversations in the `launch_conversation` action path. */
546
555
  readonly trustContext?: TrustContext;
556
+ /** Verified requester auth context for the active turn. */
557
+ readonly authContext?: AuthContext;
558
+ /** Per-turn auth snapshot, preferred for tool dispatch authorization. */
559
+ readonly currentTurnAuthContext?: AuthContext;
560
+ /** JWT-verified requester principal for the active turn. */
561
+ readonly currentTurnSourceActorPrincipalId?: string;
547
562
  readonly channelCapabilities?: {
548
563
  channel: string;
549
564
  supportsDynamicUi: boolean;
@@ -569,6 +584,14 @@ export interface SurfaceConversationContext {
569
584
  style?: string;
570
585
  data?: Record<string, unknown>;
571
586
  }>;
587
+ /**
588
+ * Activation-rail telemetry tag (daemon-only). When the model tags a
589
+ * `ui_show` surface as an activation funnel moment, the token is captured
590
+ * here so the milestone can be recorded deterministically when the user
591
+ * commits the surface (`handleSurfaceAction`). Never forwarded to the
592
+ * client.
593
+ */
594
+ activationMoment?: ActivationMomentParam;
572
595
  }
573
596
  >;
574
597
  surfaceUndoStacks: Map<string, string[]>;
@@ -613,6 +636,12 @@ export interface SurfaceConversationContext {
613
636
  display?: string;
614
637
  persistent?: boolean;
615
638
  toolCallId?: string;
639
+ /**
640
+ * Commit-timing activation-rail tag (daemon-only). Carried through to the
641
+ * persisted `ui_surface` history block so it survives a reload — never sent
642
+ * to the client.
643
+ */
644
+ activationMoment?: ActivationMomentParam;
616
645
  }>;
617
646
  /** Optional proxy for delegating computer-use actions to a connected desktop client. */
618
647
  hostCuProxy?: HostCuProxy;
@@ -1250,6 +1279,60 @@ function getRequestedSurfaceCompletionSummary(
1250
1279
  return summary || "Completed";
1251
1280
  }
1252
1281
 
1282
+ /**
1283
+ * Best-effort recorder for a single activation-funnel moment. Gated on the
1284
+ * conversation being a marked activation-rail session. Fire-and-forget: never
1285
+ * throws, never blocks or alters the surface-action flow (a failure is logged
1286
+ * and swallowed). Shared by the show-time path (`first_wow_executed`, recorded
1287
+ * when the surface renders) and the commit-time path (the other moments,
1288
+ * recorded when the user commits the surface).
1289
+ */
1290
+ function recordActivationMoment(
1291
+ ctx: SurfaceConversationContext,
1292
+ moment: ActivationMomentParam,
1293
+ ): void {
1294
+ try {
1295
+ if (!isActivationSession(ctx.conversationId)) return;
1296
+ recordActivationEvent({
1297
+ stepName: activationStepNameForMomentParam(moment),
1298
+ sessionId: ctx.conversationId,
1299
+ });
1300
+ } catch (err) {
1301
+ log.warn(
1302
+ { err, conversationId: ctx.conversationId, moment },
1303
+ "Failed to record activation moment",
1304
+ );
1305
+ }
1306
+ }
1307
+
1308
+ /**
1309
+ * Best-effort activation-funnel emission on a user surface commit.
1310
+ *
1311
+ * When the committed surface carries a commit-timing `activationMoment` tag,
1312
+ * record the corresponding funnel milestone. Show-timing moments
1313
+ * (`first_wow_executed`) are recorded at render time in `surfaceProxyResolver`
1314
+ * and are NOT stored on `surfaceState`, so they never reach this path. The tag
1315
+ * is cleared after the first record so re-entrant or repeated commits on the
1316
+ * same surface do not double-emit (and the deterministic `daemon_event_id`
1317
+ * collapses any cross-surface duplicate downstream anyway).
1318
+ *
1319
+ * Must be called only from terminal-commit paths (user clicked an action /
1320
+ * submitted / selected-and-committed), NOT from intermediate non-terminal
1321
+ * events (`selection_changed` / `content_changed` / `state_update`).
1322
+ */
1323
+ function maybeEmitActivationMoment(
1324
+ ctx: SurfaceConversationContext,
1325
+ surfaceId: string,
1326
+ ): void {
1327
+ const stored = ctx.surfaceState.get(surfaceId);
1328
+ const moment = stored?.activationMoment;
1329
+ if (!moment) return;
1330
+ // Clear the tag first so this can fire at most once per surface even if the
1331
+ // commit path is re-entered.
1332
+ stored.activationMoment = undefined;
1333
+ recordActivationMoment(ctx, moment);
1334
+ }
1335
+
1253
1336
  function completeSurfaceFromAction(
1254
1337
  ctx: SurfaceConversationContext,
1255
1338
  surfaceId: string,
@@ -1317,6 +1400,13 @@ export async function handleSurfaceAction(
1317
1400
  });
1318
1401
  markSurfaceCompleted(ctx, surfaceId, summary);
1319
1402
 
1403
+ // Terminal user commit on a standalone surface (submit, not cancel/dismiss)
1404
+ // — record an activation milestone if tagged. Must run before
1405
+ // `cleanupStandaloneSurface` clears the surface state.
1406
+ if (!isCancellation) {
1407
+ maybeEmitActivationMoment(ctx, surfaceId);
1408
+ }
1409
+
1320
1410
  // Cleanup and resolve — order matters: cleanup clears the timer
1321
1411
  // before resolve() unblocks the caller.
1322
1412
  cleanupStandaloneSurface(ctx, surfaceId);
@@ -1403,6 +1493,10 @@ export async function handleSurfaceAction(
1403
1493
  { originConversationId: ctx.conversationId, conversationId, surfaceId },
1404
1494
  "launch_conversation dispatched inline from surface action",
1405
1495
  );
1496
+ // Launching a child conversation is a terminal user commit — record an
1497
+ // activation milestone if tagged. The helper clears the tag after firing,
1498
+ // so the other commit-path call sites below can't double-emit.
1499
+ maybeEmitActivationMoment(ctx, surfaceId);
1406
1500
  return { accepted: true, conversationId };
1407
1501
  }
1408
1502
 
@@ -1557,6 +1651,12 @@ export async function handleSurfaceAction(
1557
1651
  return;
1558
1652
  }
1559
1653
 
1654
+ // Terminal user commit accepted — record the activation milestone if this
1655
+ // surface was tagged (best-effort, no-op otherwise). Deferred until after
1656
+ // the rejection check so a queue-full click doesn't over-report a moment
1657
+ // (and the one-shot tag stays intact for the user's retry).
1658
+ maybeEmitActivationMoment(ctx, surfaceId);
1659
+
1560
1660
  const requestedCompletionSummary =
1561
1661
  getRequestedSurfaceCompletionSummary(mergedData);
1562
1662
  if (requestedCompletionSummary) {
@@ -1796,6 +1896,12 @@ export async function handleSurfaceAction(
1796
1896
  return;
1797
1897
  }
1798
1898
 
1899
+ // Terminal user commit accepted — record the activation milestone if this
1900
+ // surface was tagged (best-effort, no-op otherwise). Deferred until after the
1901
+ // rejection check so a queue-full click doesn't over-report a moment (and the
1902
+ // one-shot tag stays intact for the user's retry).
1903
+ maybeEmitActivationMoment(ctx, surfaceId);
1904
+
1799
1905
  const requestedCompletionSummary =
1800
1906
  getRequestedSurfaceCompletionSummary(mergedData);
1801
1907
 
@@ -2215,7 +2321,10 @@ export async function surfaceProxyResolver(
2215
2321
  // validate at the tool-resolution layer for the same reason. The proxy
2216
2322
  // re-checks same-user (single authoritative gate); using the shared
2217
2323
  // helper keeps log payload and error wording identical at both layers.
2218
- const sourceActorPrincipalId = ctx.trustContext?.guardianPrincipalId;
2324
+ const sourceActorPrincipalId =
2325
+ ctx.currentTurnSourceActorPrincipalId ??
2326
+ ctx.currentTurnAuthContext?.actorPrincipalId ??
2327
+ ctx.authContext?.actorPrincipalId;
2219
2328
  if (targetClientId != null) {
2220
2329
  const client = assistantEventHub.getClientById(targetClientId);
2221
2330
  if (!client) {
@@ -2321,7 +2430,10 @@ export async function surfaceProxyResolver(
2321
2430
  ? input.target_client_id
2322
2431
  : undefined;
2323
2432
 
2324
- const sourceActorPrincipalId = ctx.trustContext?.guardianPrincipalId;
2433
+ const sourceActorPrincipalId =
2434
+ ctx.currentTurnSourceActorPrincipalId ??
2435
+ ctx.currentTurnAuthContext?.actorPrincipalId ??
2436
+ ctx.authContext?.actorPrincipalId;
2325
2437
  if (targetClientId != null) {
2326
2438
  const client = assistantEventHub.getClientById(targetClientId);
2327
2439
  if (!client) {
@@ -2504,6 +2616,24 @@ export async function surfaceProxyResolver(
2504
2616
  ...(a.data ? { data: a.data } : {}),
2505
2617
  }));
2506
2618
 
2619
+ // Optional activation-rail telemetry tag. Daemon-only metadata: validated
2620
+ // and ignored if invalid; never forwarded to the client.
2621
+ const activationMoment =
2622
+ typeof input.activation_moment === "string" &&
2623
+ isActivationMomentParam(input.activation_moment)
2624
+ ? input.activation_moment
2625
+ : undefined;
2626
+
2627
+ // Show-timing moments (`first_wow_executed`) record the instant the surface
2628
+ // renders — a display-only result/`work_result` card may never be committed,
2629
+ // so a commit-time emit would never fire. We record now and do NOT store the
2630
+ // tag, so the commit path won't double-emit. Commit-timing moments are
2631
+ // stored and recorded when the user commits the surface (see
2632
+ // `handleSurfaceAction` → `maybeEmitActivationMoment`).
2633
+ const storeTagForCommit =
2634
+ activationMoment !== undefined &&
2635
+ !activationMomentEmitsAtShow(activationMoment);
2636
+
2507
2637
  // Track surface state for ui_update merging (includes actions so we can
2508
2638
  // look up per-action data payloads when the client sends an action back).
2509
2639
  ctx.surfaceState.set(surfaceId, {
@@ -2511,8 +2641,13 @@ export async function surfaceProxyResolver(
2511
2641
  data,
2512
2642
  title,
2513
2643
  actions: mappedActions,
2644
+ ...(storeTagForCommit ? { activationMoment } : {}),
2514
2645
  });
2515
2646
 
2647
+ if (activationMoment !== undefined && !storeTagForCommit) {
2648
+ recordActivationMoment(ctx, activationMoment);
2649
+ }
2650
+
2516
2651
  log.info(
2517
2652
  {
2518
2653
  surfaceId,
@@ -2540,7 +2675,9 @@ export async function surfaceProxyResolver(
2540
2675
  ...(toolUseId ? { toolCallId: toolUseId } : {}),
2541
2676
  } as unknown as UiSurfaceShow);
2542
2677
 
2543
- // Track surface for persistence with the message
2678
+ // Track surface for persistence with the message. The commit-timing
2679
+ // activation tag rides along (daemon-only) so it survives history restore;
2680
+ // show-timing moments aren't stored here (already recorded at render).
2544
2681
  ctx.currentTurnSurfaces.push({
2545
2682
  surfaceId,
2546
2683
  surfaceType,
@@ -2550,6 +2687,7 @@ export async function surfaceProxyResolver(
2550
2687
  display,
2551
2688
  ...(persistent ? { persistent: true } : {}),
2552
2689
  ...(toolUseId ? { toolCallId: toolUseId } : {}),
2690
+ ...(storeTagForCommit ? { activationMoment } : {}),
2553
2691
  });
2554
2692
 
2555
2693
  if (awaitAction) {