@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
@@ -33,11 +33,6 @@ import {
33
33
  import { resolveCallSiteConfig } from "../config/llm-resolver.js";
34
34
  import { getConfig } from "../config/loader.js";
35
35
  import type { LLMCallSite, Speed } from "../config/schemas/llm.js";
36
- import {
37
- type ContextWindowManager,
38
- type ContextWindowResult,
39
- createContextSummaryMessage,
40
- } from "../context/window-manager.js";
41
36
  import type { CesClient } from "../credential-execution/client.js";
42
37
  import { EventBus } from "../events/bus.js";
43
38
  import type { AssistantDomainEvents } from "../events/domain-events.js";
@@ -67,8 +62,16 @@ import {
67
62
  createContextWindowManager,
68
63
  getContextWindowManager,
69
64
  } from "../plugins/defaults/compaction/manager-store.js";
65
+ import {
66
+ type ContextWindowManager,
67
+ type ContextWindowResult,
68
+ createContextSummaryMessage,
69
+ } from "../plugins/defaults/compaction/window-manager.js";
70
70
  import { repairHistory } from "../plugins/defaults/history-repair/terminal.js";
71
- import { buildSystemPrompt } from "../prompts/system-prompt.js";
71
+ import {
72
+ applyBootstrapTemplate,
73
+ buildSystemPrompt,
74
+ } from "../prompts/system-prompt.js";
72
75
  import type { ContentBlock, Message } from "../providers/types.js";
73
76
  import type { Provider } from "../providers/types.js";
74
77
  import {
@@ -79,6 +82,10 @@ import { broadcastMessage } from "../runtime/assistant-event-hub.js";
79
82
  import type { AuthContext } from "../runtime/auth/types.js";
80
83
  import type { InteractiveUiResult } from "../runtime/interactive-ui.js";
81
84
  import { publishSyncInvalidation } from "../runtime/sync/sync-publisher.js";
85
+ import {
86
+ type ActivationMomentParam,
87
+ isActivationMomentParam,
88
+ } from "../telemetry/activation-funnel.js";
82
89
  import { ToolExecutor } from "../tools/executor.js";
83
90
  import { getAllToolDefinitions } from "../tools/registry.js";
84
91
  import type { ToolLifecycleEvent } from "../tools/types.js";
@@ -266,6 +273,8 @@ export class Conversation {
266
273
  /** @internal */ currentTurnOverrideProfile?: string;
267
274
  /** @internal */ toolRoutedProfile?: string;
268
275
  /** @internal */ authContext?: AuthContext;
276
+ /** @internal */ currentTurnAuthContext?: AuthContext;
277
+ /** @internal */ currentTurnSourceActorPrincipalId?: string;
269
278
  /** @internal */ loadedHistoryTrustClass?: TrustClass;
270
279
  /** @internal */ loadedHistoryPersonalMemoryAllowed?: boolean;
271
280
  /** @internal */ voiceCallControlPrompt?: string;
@@ -338,6 +347,12 @@ export class Conversation {
338
347
  style?: string;
339
348
  data?: Record<string, unknown>;
340
349
  }>;
350
+ /**
351
+ * Commit-timing activation-rail tag (daemon-only). Rehydrated by
352
+ * `restoreSurfaceStateFromHistory` so a post-reload commit still records
353
+ * its funnel milestone. Never sent to the client.
354
+ */
355
+ activationMoment?: ActivationMomentParam;
341
356
  }
342
357
  >();
343
358
  /** @internal */ surfaceUndoStacks = new Map<string, string[]>();
@@ -568,6 +583,7 @@ export class Conversation {
568
583
  trustContext: this.currentTurnTrustContext,
569
584
  channelCapabilities: this.currentTurnChannelCapabilities,
570
585
  onboardingContext: this.getOnboardingContext(),
586
+ conversationId: this.conversationId,
571
587
  }),
572
588
  };
573
589
  if (configuredMaxTokens !== undefined) {
@@ -626,11 +642,11 @@ export class Conversation {
626
642
 
627
643
  /**
628
644
  * The conversation's {@link ContextWindowManager}, owned by the compaction
629
- * module's per-conversation store. The constructor registers it there; this
630
- * accessor resolves it on demand so the conversation holds no separate
631
- * handle. Present for the conversation's whole in-memory lifetime (registered
632
- * at construction, released on teardown), so a live conversation always
633
- * resolves an instance.
645
+ * module's per-conversation store. The constructor builds and registers it
646
+ * there; this accessor resolves it on demand so the conversation holds no
647
+ * separate handle. Present for the conversation's whole in-memory lifetime
648
+ * (registered at construction, released on teardown), so a live conversation
649
+ * always resolves an instance.
634
650
  */
635
651
  /** @internal */ get contextWindowManager(): ContextWindowManager {
636
652
  const manager = getContextWindowManager(this.conversationId);
@@ -646,6 +662,13 @@ export class Conversation {
646
662
 
647
663
  setOnboardingContext(ctx: OnboardingContext): void {
648
664
  this.onboardingContext = ctx;
665
+ // Reseed BOOTSTRAP.md and mark the activation session at the earliest point
666
+ // the conversation knows its bootstrap selection — before the first turn's
667
+ // tool resolution, which `buildSystemPrompt` is too late for. See
668
+ // `applyBootstrapTemplate`.
669
+ if (ctx.bootstrapTemplate) {
670
+ applyBootstrapTemplate(ctx.bootstrapTemplate, this.conversationId);
671
+ }
649
672
  }
650
673
 
651
674
  getOnboardingContext(): OnboardingContext | undefined {
@@ -687,6 +710,7 @@ export class Conversation {
687
710
  trustContext: this.currentTurnTrustContext,
688
711
  channelCapabilities: this.currentTurnChannelCapabilities,
689
712
  onboardingContext: this.getOnboardingContext(),
713
+ conversationId: this.conversationId,
690
714
  });
691
715
  const tools = getAllToolDefinitions();
692
716
  const provider = this.provider;
@@ -990,6 +1014,14 @@ export class Conversation {
990
1014
  for (const block of msg.content) {
991
1015
  const b = block as unknown as Record<string, unknown>;
992
1016
  if (b.type === "ui_surface" && typeof b.surfaceId === "string") {
1017
+ // Rehydrate the daemon-only commit-timing activation tag so a commit
1018
+ // after reload still records its funnel milestone. Validated and
1019
+ // dropped if malformed; this field never reaches the client.
1020
+ const activationMoment =
1021
+ typeof b.activationMoment === "string" &&
1022
+ isActivationMomentParam(b.activationMoment)
1023
+ ? b.activationMoment
1024
+ : undefined;
993
1025
  this.surfaceState.set(b.surfaceId, {
994
1026
  surfaceType: (b.surfaceType ?? "dynamic_page") as SurfaceType,
995
1027
  data: (b.data ?? {}) as SurfaceData,
@@ -1002,6 +1034,7 @@ export class Conversation {
1002
1034
  data?: Record<string, unknown>;
1003
1035
  }>)
1004
1036
  : undefined,
1037
+ ...(activationMoment ? { activationMoment } : {}),
1005
1038
  });
1006
1039
  }
1007
1040
  }
@@ -1330,7 +1363,9 @@ export class Conversation {
1330
1363
 
1331
1364
  ensureHostProxiesForTurn(
1332
1365
  sourceInterface: import("../channels/types.js").InterfaceId | undefined,
1333
- sourceActorPrincipalId = this.trustContext?.guardianPrincipalId,
1366
+ sourceActorPrincipalId = this.currentTurnSourceActorPrincipalId ??
1367
+ this.currentTurnAuthContext?.actorPrincipalId ??
1368
+ this.authContext?.actorPrincipalId,
1334
1369
  ): void {
1335
1370
  if (
1336
1371
  shouldAttachHostProxyForCapability(
@@ -1438,7 +1473,7 @@ export class Conversation {
1438
1473
  const messagesToCompact =
1439
1474
  slackChronologicalContext?.messages ?? this.messages;
1440
1475
  const result = await defaultCompact({
1441
- manager: this.contextWindowManager,
1476
+ conversationId: this.conversationId,
1442
1477
  messages: messagesToCompact,
1443
1478
  signal: this.abortController?.signal ?? undefined,
1444
1479
  force: true,
@@ -6,8 +6,9 @@
6
6
  * retries. That transformation is transient — the stored message row keeps the
7
7
  * full-size image block, so the rejected image is rehydrated on every later
8
8
  * turn and keeps re-entering the model's context. This module makes the
9
- * downgrade durable for images that can *never* be transmitted, so a rejected
10
- * upload cannot resurface after the user re-uploads a smaller version.
9
+ * downgrade durable: the oversized block is rewritten to its downscaled form,
10
+ * or to a text note when it cannot be shrunk on this host, so a rejected image
11
+ * cannot resurface and re-reject on every later turn.
11
12
  */
12
13
 
13
14
  import { optimizeImageForTransport } from "../agent/image-optimize.js";
@@ -40,19 +41,25 @@ export const UNSENDABLE_IMAGE_NOTE =
40
41
  "(An image was attached but could not be sent — its dimensions exceed the provider limit and automatic resize was not available. Please resize the image and try again.)";
41
42
 
42
43
  /**
43
- * True when a stored image block can never be transmitted to the provider: it
44
- * violates a provider hard limit (per-side pixel cap or payload size) and
45
- * cannot be shrunk to fit (re-optimization is a no-op).
44
+ * Replacement for an image that violates a provider hard limit (per-side pixel
45
+ * cap or payload size), or null when the image is within limits and should be
46
+ * left untouched. Gating on the provider hard caps is what keeps still-sendable
47
+ * images intact: a normally sized image is left alone rather than being noted or
48
+ * needlessly rewritten.
46
49
  *
47
- * Stored blocks are already post-optimization, so a block that is still
48
- * oversized here only stays oversized because resizing is unavailable on this
49
- * host (e.g. `sips` is absent off macOS, or the format is unsupported). A
50
- * downscalable image would have been reduced at upload time and would not reach
51
- * this predicate, so it is left untouched.
50
+ * An oversized image that can be shrunk is rewritten to its downscaled form; one
51
+ * that cannot be shrunk on this host (resize is a no-op, e.g. `sips` is absent
52
+ * off macOS or the format is unsupported) is replaced with a text note.
53
+ *
54
+ * Shared by the in-memory recovery transform and this durable persist pass so
55
+ * both apply the identical rule. Persisting the downscaled form is what lets a
56
+ * poisoned conversation durably self-heal — the latest tool-result media is kept
57
+ * in context, so without it the original oversized block rehydrates and
58
+ * re-rejects on every later turn.
52
59
  */
53
- function isImagePermanentlyUnsendable(
60
+ export function oversizedImageReplacement(
54
61
  block: Extract<ContentBlock, { type: "image" }>,
55
- ): boolean {
62
+ ): ContentBlock | null {
56
63
  const payloadBytes = block.source.data.length;
57
64
  const dims = parseImageDimensions(block.source.data, block.source.media_type);
58
65
  const exceedsDimensionCap =
@@ -60,24 +67,35 @@ function isImagePermanentlyUnsendable(
60
67
  (dims.width > PROVIDER_MAX_IMAGE_DIMENSION ||
61
68
  dims.height > PROVIDER_MAX_IMAGE_DIMENSION);
62
69
  const exceedsPayloadCap = payloadBytes > PROVIDER_MAX_IMAGE_PAYLOAD_BYTES;
63
- if (!exceedsDimensionCap && !exceedsPayloadCap) return false;
70
+ if (!exceedsDimensionCap && !exceedsPayloadCap) return null;
64
71
 
65
72
  const optimized = optimizeImageForTransport(
66
73
  block.source.data,
67
74
  block.source.media_type,
68
75
  );
69
- return optimized.data === block.source.data;
76
+ if (optimized.data !== block.source.data) {
77
+ return {
78
+ type: "image",
79
+ source: {
80
+ type: "base64",
81
+ media_type: optimized.mediaType,
82
+ data: optimized.data,
83
+ },
84
+ };
85
+ }
86
+ return { type: "text", text: UNSENDABLE_IMAGE_NOTE };
70
87
  }
71
88
 
72
89
  /**
73
- * Rewrite every stored message in a conversation that holds a permanently
74
- * unsendable image, replacing those image blocks with {@link
75
- * UNSENDABLE_IMAGE_NOTE}. Reads stored content directly (not the in-memory,
76
- * injection-enriched copy) so injected prefixes and hydrated source paths are
77
- * never written back.
90
+ * Rewrite every stored message in a conversation that holds an oversized image
91
+ * the provider rejects — whether a top-level block or one nested in a
92
+ * tool_result's contentBlocks — replacing it with its downscaled form, or with
93
+ * {@link UNSENDABLE_IMAGE_NOTE} when it cannot be shrunk on this host. Reads
94
+ * stored content directly (not the in-memory, injection-enriched copy) so
95
+ * injected prefixes and hydrated source paths are never written back.
78
96
  *
79
- * Idempotent: once an image is replaced by the note there is no image block
80
- * left to match, so re-running is a no-op. Returns the number of rewritten
97
+ * Idempotent: a downscaled image is within limits and a note is no longer an
98
+ * image, so neither matches on a second run. Returns the number of rewritten
81
99
  * messages.
82
100
  */
83
101
  export function persistUnsendableImageDowngrades(
@@ -99,10 +117,29 @@ export function persistUnsendableImageDowngrades(
99
117
 
100
118
  let changed = false;
101
119
  const next = (parsed as ContentBlock[]).map((block): ContentBlock => {
102
- if (block.type !== "image") return block;
103
- if (!isImagePermanentlyUnsendable(block)) return block;
104
- changed = true;
105
- return { type: "text", text: UNSENDABLE_IMAGE_NOTE };
120
+ if (block.type === "image") {
121
+ const replacement = oversizedImageReplacement(block);
122
+ if (!replacement) return block;
123
+ changed = true;
124
+ return replacement;
125
+ }
126
+ // Images returned by a tool (e.g. browser_screenshot) live inside the
127
+ // tool_result's contentBlocks, not as top-level blocks. Downgrade them
128
+ // in place so the tool_use/tool_result pairing stays intact.
129
+ if (block.type === "tool_result" && block.contentBlocks?.length) {
130
+ let nestedChanged = false;
131
+ const contentBlocks = block.contentBlocks.map((cb): ContentBlock => {
132
+ if (cb.type !== "image") return cb;
133
+ const replacement = oversizedImageReplacement(cb);
134
+ if (!replacement) return cb;
135
+ nestedChanged = true;
136
+ return replacement;
137
+ });
138
+ if (!nestedChanged) return block;
139
+ changed = true;
140
+ return { ...block, contentBlocks };
141
+ }
142
+ return block;
106
143
  });
107
144
  if (!changed) continue;
108
145
 
@@ -195,7 +195,7 @@ async function prepareConversationForMessage(
195
195
  "wiring in conversation-routes.ts into a shared helper.",
196
196
  );
197
197
  }
198
- const sourceActorPrincipalId = conversation.trustContext?.guardianPrincipalId;
198
+ const sourceActorPrincipalId = conversation.authContext?.actorPrincipalId;
199
199
  // CU is per-conversation (owns step count, AX tree history, loop detection).
200
200
  if (
201
201
  shouldAttachHostProxyForCapability(
@@ -0,0 +1,41 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ mock.module("../../util/logger.js", () => ({
4
+ getLogger: () =>
5
+ new Proxy({} as Record<string, unknown>, {
6
+ get: () => () => {},
7
+ }),
8
+ }));
9
+
10
+ import {
11
+ isActivationSession,
12
+ markActivationSession,
13
+ } from "../activation-session-store.js";
14
+ import { getDb } from "../db-connection.js";
15
+ import { initializeDb } from "../db-init.js";
16
+ import { activationSessions } from "../schema.js";
17
+
18
+ initializeDb();
19
+
20
+ describe("activation-session-store", () => {
21
+ beforeEach(() => {
22
+ getDb().delete(activationSessions).run();
23
+ });
24
+
25
+ test("marks a conversation and reports it as an activation session", () => {
26
+ markActivationSession("c1");
27
+ expect(isActivationSession("c1")).toBe(true);
28
+ });
29
+
30
+ test("unmarked conversations are not activation sessions", () => {
31
+ expect(isActivationSession("nope")).toBe(false);
32
+ });
33
+
34
+ test("marking the same conversation twice is idempotent", () => {
35
+ markActivationSession("c1");
36
+ markActivationSession("c1");
37
+ expect(isActivationSession("c1")).toBe(true);
38
+ const rows = getDb().select().from(activationSessions).all();
39
+ expect(rows.length).toBe(1);
40
+ });
41
+ });
@@ -0,0 +1,80 @@
1
+ import { beforeEach, describe, expect, mock, test } from "bun:test";
2
+
3
+ // Silence the logger.
4
+ mock.module("../../util/logger.js", () => ({
5
+ getLogger: () =>
6
+ new Proxy({} as Record<string, unknown>, {
7
+ get: () => () => {},
8
+ }),
9
+ }));
10
+
11
+ // Mutable usage-data gate, flipped per-test.
12
+ let collectUsageData = true;
13
+ mock.module("../../config/loader.js", () => ({
14
+ getConfig: () => ({ collectUsageData }),
15
+ }));
16
+
17
+ import { getDb } from "../db-connection.js";
18
+ import { initializeDb } from "../db-init.js";
19
+ import {
20
+ queryUnreportedOnboardingEvents,
21
+ recordActivationEvent,
22
+ } from "../onboarding-events-store.js";
23
+ import { onboardingEvents } from "../schema.js";
24
+
25
+ initializeDb();
26
+
27
+ function resetTable(): void {
28
+ getDb().delete(onboardingEvents).run();
29
+ }
30
+
31
+ describe("onboarding-events-store: recordActivationEvent", () => {
32
+ beforeEach(() => {
33
+ collectUsageData = true;
34
+ resetTable();
35
+ });
36
+
37
+ test("persists an activation funnel event that round-trips through the query", () => {
38
+ const event = recordActivationEvent({
39
+ stepName: "activation_moment_1_complete",
40
+ sessionId: "conv-1",
41
+ });
42
+ expect(event).not.toBeNull();
43
+
44
+ const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
45
+ expect(rows).toHaveLength(1);
46
+ const row = rows[0]!;
47
+ expect(row.stepName).toBe("activation_moment_1_complete");
48
+ expect(row.stepIndex).toBe(1);
49
+ expect(row.funnelVersion).toBe("activation_v1_2026_06");
50
+ expect(row.screen).toBe("activation_moment_1_complete");
51
+ expect(row.abVariant).toBe("variant-a");
52
+ expect(row.sessionId).toBe("conv-1");
53
+ expect(row.completedAt).toBe(new Date(row.createdAt).toISOString());
54
+ });
55
+
56
+ test("honors an explicit abVariant override", () => {
57
+ recordActivationEvent({
58
+ stepName: "activation_moment_2_complete",
59
+ sessionId: "conv-2",
60
+ abVariant: "variant-b",
61
+ });
62
+
63
+ const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
64
+ expect(rows).toHaveLength(1);
65
+ expect(rows[0]!.abVariant).toBe("variant-b");
66
+ expect(rows[0]!.stepIndex).toBe(2);
67
+ });
68
+
69
+ test("returns null and writes no row when collectUsageData is disabled", () => {
70
+ collectUsageData = false;
71
+ const event = recordActivationEvent({
72
+ stepName: "activation_moment_1_complete",
73
+ sessionId: "conv-3",
74
+ });
75
+ expect(event).toBeNull();
76
+
77
+ const rows = queryUnreportedOnboardingEvents(0, undefined, 10);
78
+ expect(rows).toHaveLength(0);
79
+ });
80
+ });
@@ -0,0 +1,43 @@
1
+ import { eq } from "drizzle-orm";
2
+
3
+ import { getLogger } from "../util/logger.js";
4
+ import { getDb } from "./db-connection.js";
5
+ import { activationSessions } from "./schema.js";
6
+
7
+ const log = getLogger("activation-session-store");
8
+
9
+ /**
10
+ * Mark a conversation as an activation-rail session. Idempotent (the row's
11
+ * primary key is the conversation id) and best-effort: a write failure is
12
+ * logged and swallowed so it never blocks the turn that triggered it.
13
+ */
14
+ export function markActivationSession(conversationId: string): void {
15
+ try {
16
+ getDb()
17
+ .insert(activationSessions)
18
+ .values({ conversationId, createdAt: Date.now() })
19
+ .onConflictDoNothing()
20
+ .run();
21
+ } catch (err) {
22
+ log.warn({ err, conversationId }, "Failed to mark activation session");
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Whether the given conversation was started on the activation rail. Best-effort:
28
+ * a read failure is logged and treated as "not an activation session".
29
+ */
30
+ export function isActivationSession(conversationId: string): boolean {
31
+ try {
32
+ const row = getDb()
33
+ .select({ conversationId: activationSessions.conversationId })
34
+ .from(activationSessions)
35
+ .where(eq(activationSessions.conversationId, conversationId))
36
+ .limit(1)
37
+ .get();
38
+ return row !== undefined;
39
+ } catch (err) {
40
+ log.warn({ err, conversationId }, "Failed to read activation session");
41
+ return false;
42
+ }
43
+ }
@@ -16,6 +16,7 @@ import { getDb, getSqlite } from "./db-connection.js";
16
16
  import { migrateToolCreatedItems } from "./graph/bootstrap.js";
17
17
  import {
18
18
  addCoreColumns,
19
+ createActivationSessionsTable,
19
20
  createApprovalPromptTsTrackerTable,
20
21
  createAssistantInboxTables,
21
22
  createAuthFallbackEventsTable,
@@ -161,6 +162,7 @@ import {
161
162
  migrateOAuthProvidersScopeSeparator,
162
163
  migrateOAuthProvidersTokenAuthMethodDefault,
163
164
  migrateOAuthProvidersTokenExchangeBodyFormat,
165
+ migrateOnboardingEventsFunnelColumns,
164
166
  migrateOnboardingEventsPriorAssistants,
165
167
  migrateProviderConnectionBaseUrlAndModels,
166
168
  migrateProviderConnectionStatusLabel,
@@ -480,6 +482,8 @@ export function initializeDb(): void {
480
482
  migrateMessagesRoleCreatedAtIndex,
481
483
  createAuthFallbackEventsTable,
482
484
  migrateAcpSessionHistoryCwd,
485
+ migrateOnboardingEventsFunnelColumns,
486
+ createActivationSessionsTable,
483
487
  ];
484
488
 
485
489
  // Run each migration step, catching and logging individual failures so one
@@ -0,0 +1,46 @@
1
+ import { type DrizzleDb, getSqliteFrom } from "../db-connection.js";
2
+
3
+ const TABLE = "onboarding_events";
4
+ const COLUMNS: Array<{ name: string; type: string }> = [
5
+ { name: "session_id", type: "TEXT" },
6
+ { name: "step_name", type: "TEXT" },
7
+ { name: "step_index", type: "INTEGER" },
8
+ { name: "completed_at", type: "TEXT" },
9
+ { name: "funnel_version", type: "TEXT" },
10
+ ];
11
+
12
+ /**
13
+ * Add nullable funnel-tracking columns to `onboarding_events`.
14
+ *
15
+ * The original table (#30733) recorded one row per onboarding screen but had
16
+ * no way to stitch those rows into an ordered, per-attempt funnel: there was
17
+ * no session correlator, no step ordering, and no completion timestamp. These
18
+ * columns let the activation-funnel reporting attribute each event to a single
19
+ * onboarding attempt (`session_id`), order steps within it (`step_name` /
20
+ * `step_index`), record when a step finished (`completed_at`), and version the
21
+ * funnel shape so changes to the step set don't silently corrupt historical
22
+ * conversion math (`funnel_version`).
23
+ *
24
+ * All columns are nullable — `NULL` for rows persisted before this migration
25
+ * ran.
26
+ *
27
+ * Idempotent — re-running is a no-op once the columns exist. Pure DDL with a
28
+ * PRAGMA guard, no registry entry needed (matches the 252 / 261 / 264 / 265 /
29
+ * 267 pattern).
30
+ */
31
+ export function migrateOnboardingEventsFunnelColumns(
32
+ database: DrizzleDb,
33
+ ): void {
34
+ const raw = getSqliteFrom(database);
35
+
36
+ const columns = raw.query(`PRAGMA table_info(${TABLE})`).all() as Array<{
37
+ name: string;
38
+ }>;
39
+ const columnNames = new Set(columns.map((c) => c.name));
40
+
41
+ for (const { name, type } of COLUMNS) {
42
+ if (!columnNames.has(name)) {
43
+ raw.exec(`ALTER TABLE ${TABLE} ADD COLUMN ${name} ${type}`);
44
+ }
45
+ }
46
+ }
@@ -0,0 +1,15 @@
1
+ import type { DrizzleDb } from "../db-connection.js";
2
+
3
+ /**
4
+ * Create the activation_sessions table. One row per conversation that was
5
+ * started on the activation-rail bootstrap template, used by the activation
6
+ * funnel telemetry to scope events to activation conversations.
7
+ */
8
+ export function createActivationSessionsTable(database: DrizzleDb): void {
9
+ database.run(/*sql*/ `
10
+ CREATE TABLE IF NOT EXISTS activation_sessions (
11
+ conversation_id TEXT PRIMARY KEY,
12
+ created_at INTEGER NOT NULL
13
+ )
14
+ `);
15
+ }
@@ -260,6 +260,8 @@ export { migrateMessagesRoleCreatedAtIndex } from "./270-messages-role-created-a
260
260
  export { migrateScheduleSourceConversation } from "./270-schedule-source-conversation.js";
261
261
  export { createAuthFallbackEventsTable } from "./271-create-auth-fallback-events.js";
262
262
  export { migrateAcpSessionHistoryCwd } from "./272-acp-session-history-cwd.js";
263
+ export { migrateOnboardingEventsFunnelColumns } from "./273-onboarding-events-funnel-columns.js";
264
+ export { createActivationSessionsTable } from "./274-create-activation-sessions.js";
263
265
  export {
264
266
  MIGRATION_REGISTRY,
265
267
  type MigrationRegistryEntry,