@vellumai/assistant 0.8.9-staging.2 → 0.8.9-staging.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/activation-funnel-telemetry.md +310 -0
- package/package.json +1 -1
- package/src/__tests__/activation-early-marking.test.ts +120 -0
- package/src/__tests__/agent-loop-output-hooks.test.ts +13 -13
- package/src/__tests__/approval-cascade.test.ts +1 -1
- package/src/__tests__/compaction-direct.test.ts +32 -18
- package/src/__tests__/compaction-events.test.ts +2 -2
- package/src/__tests__/compaction.benchmark.test.ts +1 -1
- package/src/__tests__/context-overflow-reducer.test.ts +5 -5
- package/src/__tests__/context-window-manager-compact-retry.test.ts +1 -1
- package/src/__tests__/conversation-abort-tool-results.test.ts +1 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +1 -1
- package/src/__tests__/conversation-error.test.ts +15 -1
- package/src/__tests__/conversation-history-web-search.test.ts +5 -0
- package/src/__tests__/conversation-media-retry.test.ts +1 -1
- package/src/__tests__/conversation-process-app-control-preactivation.test.ts +40 -0
- package/src/__tests__/conversation-process-callsite.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +1 -1
- package/src/__tests__/conversation-runtime-assembly.test.ts +71 -0
- package/src/__tests__/conversation-slash-queue.test.ts +1 -1
- package/src/__tests__/conversation-slash-unknown.test.ts +1 -1
- package/src/__tests__/conversation-speed-override.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +395 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +44 -0
- package/src/__tests__/conversation-undo.test.ts +2 -2
- package/src/__tests__/conversation-workspace-cache-state.test.ts +1 -1
- package/src/__tests__/conversation-workspace-injection.test.ts +1 -1
- package/src/__tests__/conversation-workspace-tool-tracking.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +36 -0
- package/src/__tests__/history-repair-hook.test.ts +2 -0
- package/src/__tests__/memory-retrieval-hook.test.ts +1 -2
- package/src/__tests__/persist-unsendable-image-downscale.test.ts +145 -0
- package/src/__tests__/persist-unsendable-image.test.ts +97 -1
- package/src/__tests__/post-turn-tool-result-truncation.test.ts +69 -0
- package/src/__tests__/skill-feature-flags-integration.test.ts +5 -7
- package/src/__tests__/title-generate-hook.test.ts +2 -0
- package/src/__tests__/web-fetch.test.ts +45 -0
- package/src/acp/__tests__/helpers/acp-config-stub.ts +0 -2
- package/src/acp/resolve-agent.test.ts +0 -56
- package/src/acp/resolve-agent.ts +10 -38
- package/src/agent/loop.ts +13 -27
- package/src/cli/lib/__tests__/install-from-github.test.ts +232 -29
- package/src/cli/lib/__tests__/plugin-details.test.ts +28 -19
- package/src/cli/lib/__tests__/plugin-marketplace.test.ts +57 -7
- package/src/cli/lib/__tests__/search-plugins.test.ts +17 -10
- package/src/cli/lib/install-from-github.ts +258 -41
- package/src/cli/lib/plugin-details.ts +20 -13
- package/src/cli/lib/plugin-marketplace.ts +23 -5
- package/src/cli/lib/search-plugins.ts +14 -8
- package/src/config/acp-defaults.ts +3 -3
- package/src/config/acp-schema.ts +1 -7
- package/src/config/bundled-skills/acp/SKILL.md +4 -17
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/feature-flag-registry.json +3 -18
- package/src/context/post-turn-tool-result-truncation.ts +39 -1
- package/src/daemon/conversation-agent-loop-handlers.ts +8 -1
- package/src/daemon/conversation-agent-loop.ts +78 -78
- package/src/daemon/conversation-error.ts +31 -4
- package/src/daemon/conversation-history.ts +1 -1
- package/src/daemon/conversation-media-retry.ts +19 -6
- package/src/daemon/conversation-messaging.ts +17 -0
- package/src/daemon/conversation-process.ts +14 -5
- package/src/daemon/conversation-queue-manager.ts +8 -0
- package/src/daemon/conversation-runtime-assembly.ts +37 -1
- package/src/daemon/conversation-surfaces.ts +141 -3
- package/src/daemon/conversation.ts +48 -13
- package/src/daemon/persist-unsendable-image.ts +62 -25
- package/src/daemon/process-message.ts +1 -1
- package/src/memory/__tests__/activation-session-store.test.ts +41 -0
- package/src/memory/__tests__/onboarding-events-store.test.ts +80 -0
- package/src/memory/activation-session-store.ts +43 -0
- package/src/memory/db-init.ts +4 -0
- package/src/memory/migrations/273-onboarding-events-funnel-columns.ts +46 -0
- package/src/memory/migrations/274-create-activation-sessions.ts +15 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/onboarding-events-store.ts +66 -18
- package/src/memory/schema/infrastructure.ts +13 -0
- package/src/messaging/providers/telegram-bot/api.ts +14 -5
- package/src/notifications/adapters/telegram.ts +7 -1
- package/src/plugin-api/constants.ts +2 -2
- package/src/plugin-api/index.ts +2 -2
- package/src/plugin-api/types.ts +19 -5
- package/src/plugins/defaults/compaction/compact.ts +24 -15
- package/src/plugins/defaults/compaction/context-overflow-reducer.ts +4 -4
- package/src/plugins/defaults/compaction/manager-store.ts +1 -1
- package/src/{context → plugins/defaults/compaction}/window-manager.ts +12 -12
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +12 -18
- package/src/prompts/system-prompt.ts +61 -10
- package/src/prompts/templates/BOOTSTRAP-ACTIVATION-RAIL.md +37 -2
- package/src/providers/openai/__tests__/vision-not-supported.test.ts +75 -0
- package/src/providers/openai/chat-completions-provider.ts +25 -0
- package/src/runtime/routes/__tests__/stt-routes.test.ts +112 -0
- package/src/runtime/routes/acp-routes.test.ts +3 -20
- package/src/runtime/routes/conversation-routes.ts +2 -0
- package/src/runtime/routes/playground/__tests__/force-compact.test.ts +1 -1
- package/src/runtime/routes/stt-routes.ts +45 -12
- package/src/runtime/routes/workspace-routes.ts +50 -15
- package/src/telemetry/__tests__/activation-funnel.test.ts +95 -0
- package/src/telemetry/activation-funnel.ts +167 -0
- package/src/telemetry/types.ts +13 -0
- package/src/telemetry/usage-telemetry-reporter.test.ts +154 -0
- package/src/telemetry/usage-telemetry-reporter.ts +26 -1
- package/src/tools/acp/list-agents.test.ts +2 -18
- package/src/tools/acp/list-agents.ts +3 -15
- package/src/tools/acp/spawn.test.ts +0 -10
- package/src/tools/browser/browser-execution.ts +12 -2
- package/src/tools/network/web-fetch.ts +65 -24
- package/src/tools/ui-surface/definitions.ts +7 -0
- package/src/acp/feature-gate.test.ts +0 -48
- package/src/acp/feature-gate.ts +0 -34
|
@@ -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 {
|
|
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
|
|
630
|
-
* accessor resolves it on demand so the conversation holds no
|
|
631
|
-
* handle. Present for the conversation's whole in-memory lifetime
|
|
632
|
-
* at construction, released on teardown), so a live conversation
|
|
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.
|
|
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
|
-
|
|
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
|
|
10
|
-
*
|
|
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
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
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
|
-
*
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
|
60
|
+
export function oversizedImageReplacement(
|
|
54
61
|
block: Extract<ContentBlock, { type: "image" }>,
|
|
55
|
-
):
|
|
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
|
|
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
|
-
|
|
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
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
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:
|
|
80
|
-
*
|
|
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
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
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.
|
|
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
|
+
}
|
package/src/memory/db-init.ts
CHANGED
|
@@ -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,
|