@vellumai/assistant 0.8.9-staging.1 → 0.8.9-staging.2
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/ARCHITECTURE.md +6 -6
- package/openapi.yaml +6 -7
- package/package.json +1 -1
- package/src/__tests__/agent-loop-exit-reason.test.ts +33 -18
- package/src/__tests__/app-builder-tool-scripts.test.ts +21 -0
- package/src/__tests__/app-executors.test.ts +132 -0
- package/src/__tests__/compactor-web-search-strip.test.ts +213 -0
- package/src/__tests__/context-overflow-reducer.test.ts +1 -1
- package/src/__tests__/conversation-agent-loop-inference-profile.test.ts +19 -16
- package/src/__tests__/conversation-agent-loop-overflow.test.ts +48 -29
- package/src/__tests__/conversation-agent-loop.test.ts +48 -96
- package/src/__tests__/conversation-history-web-search.test.ts +1 -1
- package/src/__tests__/conversation-provider-retry-repair.test.ts +52 -49
- package/src/__tests__/memory-retrieval-hook.test.ts +68 -30
- package/src/__tests__/pre-model-call-sanitize.test.ts +109 -0
- package/src/acp/__tests__/helpers/exec-file-stub.ts +12 -7
- package/src/acp/__tests__/prepare-agent-env.test.ts +0 -40
- package/src/acp/__tests__/session-manager-resume.test.ts +179 -27
- package/src/acp/auto-install.test.ts +130 -46
- package/src/acp/auto-install.ts +86 -31
- package/src/acp/prepare-agent-env.ts +8 -9
- package/src/acp/resolve-agent.test.ts +26 -123
- package/src/acp/resolve-agent.ts +26 -85
- package/src/acp/resume-hint.ts +1 -3
- package/src/acp/session-manager.ts +32 -20
- package/src/acp/types.ts +0 -8
- package/src/agent/loop.ts +42 -26
- package/src/config/bundled-skills/acp/SKILL.md +14 -14
- package/src/config/bundled-skills/acp/TOOLS.json +2 -2
- package/src/config/bundled-skills/app-builder/SKILL.md +3 -3
- package/src/config/bundled-skills/app-builder/TOOLS.json +43 -2
- package/src/config/bundled-skills/app-builder/tools/app-update.ts +18 -0
- package/src/config/bundled-tool-registry.ts +2 -0
- package/src/config/call-site-defaults.ts +0 -2
- package/src/config/feature-flag-registry.json +1 -1
- package/src/config/schemas/call-site-catalog.ts +0 -14
- package/src/config/schemas/llm.ts +0 -2
- package/src/context/compactor.ts +22 -4
- package/src/daemon/conversation-agent-loop.ts +15 -57
- package/src/daemon/conversation.ts +23 -3
- package/src/daemon/disk-pressure-policy.ts +0 -1
- package/src/daemon/lifecycle.ts +0 -7
- package/src/{daemon → plugins/defaults/compaction}/context-overflow-reducer.ts +7 -7
- package/src/plugins/defaults/compaction/manager-store.ts +13 -0
- package/src/plugins/defaults/memory-retrieval/hooks/post-compact.ts +6 -23
- package/src/plugins/defaults/memory-retrieval/hooks/user-prompt-submit-temp.ts +24 -25
- package/src/runtime/routes/__tests__/acp-routes.test.ts +71 -39
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +29 -4
- package/src/runtime/routes/plugins-routes.ts +10 -9
- package/src/tools/acp/list-agents.test.ts +2 -2
- package/src/tools/acp/spawn.test.ts +103 -225
- package/src/tools/acp/spawn.ts +10 -120
- package/src/tools/apps/executors.ts +153 -42
- package/src/proactive-artifact/aux-message-injector.ts +0 -97
- package/src/proactive-artifact/decision.test.ts +0 -226
- package/src/proactive-artifact/decision.ts +0 -165
- package/src/proactive-artifact/index.ts +0 -7
- package/src/proactive-artifact/job.test.ts +0 -962
- package/src/proactive-artifact/job.ts +0 -372
- package/src/proactive-artifact/message-copy.ts +0 -58
- package/src/proactive-artifact/trigger-state.test.ts +0 -286
- package/src/proactive-artifact/trigger-state.ts +0 -123
|
@@ -39,6 +39,20 @@ const conversationRuntimeAssemblyRealSnapshot = {
|
|
|
39
39
|
|
|
40
40
|
// ── Module mocks (must precede imports of the module under test) ─────
|
|
41
41
|
|
|
42
|
+
// The real AgentLoop resolves the per-conversation ContextWindowManager from
|
|
43
|
+
// the compaction store keyed by conversationId. These overflow tests build
|
|
44
|
+
// fake conversations whose manager is a canned stub, so register each stub in a
|
|
45
|
+
// map the mocked store reads from.
|
|
46
|
+
const fakeContextWindowManagers = new Map<string, unknown>();
|
|
47
|
+
mock.module("../plugins/defaults/compaction/manager-store.js", () => ({
|
|
48
|
+
createContextWindowManager: () => undefined,
|
|
49
|
+
getContextWindowManager: (conversationId: string) =>
|
|
50
|
+
fakeContextWindowManagers.get(conversationId),
|
|
51
|
+
disposeContextWindowManager: (conversationId: string) => {
|
|
52
|
+
fakeContextWindowManagers.delete(conversationId);
|
|
53
|
+
},
|
|
54
|
+
}));
|
|
55
|
+
|
|
42
56
|
mock.module("../util/logger.js", () => ({
|
|
43
57
|
getLogger: () =>
|
|
44
58
|
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
@@ -137,35 +151,38 @@ mock.module("../context/token-estimator.js", () => ({
|
|
|
137
151
|
let mockReducerStepFn:
|
|
138
152
|
| ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
|
|
139
153
|
| null = null;
|
|
140
|
-
mock.module(
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
154
|
+
mock.module(
|
|
155
|
+
"../plugins/defaults/compaction/context-overflow-reducer.js",
|
|
156
|
+
() => ({
|
|
157
|
+
createInitialReducerState: () => ({
|
|
158
|
+
appliedTiers: [],
|
|
159
|
+
injectionMode: "full" as const,
|
|
160
|
+
exhausted: false,
|
|
161
|
+
}),
|
|
162
|
+
reduceContextOverflow: async (
|
|
163
|
+
msgs: Message[],
|
|
164
|
+
cfg: unknown,
|
|
165
|
+
state: unknown,
|
|
166
|
+
) => {
|
|
167
|
+
if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
|
|
168
|
+
return {
|
|
169
|
+
messages: msgs,
|
|
170
|
+
tier: "forced_compaction",
|
|
171
|
+
state: {
|
|
172
|
+
appliedTiers: [
|
|
173
|
+
"forced_compaction",
|
|
174
|
+
"tool_result_truncation",
|
|
175
|
+
"media_stubbing",
|
|
176
|
+
"injection_downgrade",
|
|
177
|
+
],
|
|
178
|
+
injectionMode: "full",
|
|
179
|
+
exhausted: true,
|
|
180
|
+
},
|
|
181
|
+
estimatedTokens: 1000,
|
|
182
|
+
};
|
|
183
|
+
},
|
|
145
184
|
}),
|
|
146
|
-
|
|
147
|
-
msgs: Message[],
|
|
148
|
-
cfg: unknown,
|
|
149
|
-
state: unknown,
|
|
150
|
-
) => {
|
|
151
|
-
if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
|
|
152
|
-
return {
|
|
153
|
-
messages: msgs,
|
|
154
|
-
tier: "forced_compaction",
|
|
155
|
-
state: {
|
|
156
|
-
appliedTiers: [
|
|
157
|
-
"forced_compaction",
|
|
158
|
-
"tool_result_truncation",
|
|
159
|
-
"media_stubbing",
|
|
160
|
-
"injection_downgrade",
|
|
161
|
-
],
|
|
162
|
-
injectionMode: "full",
|
|
163
|
-
exhausted: true,
|
|
164
|
-
},
|
|
165
|
-
estimatedTokens: 1000,
|
|
166
|
-
};
|
|
167
|
-
},
|
|
168
|
-
}));
|
|
185
|
+
);
|
|
169
186
|
|
|
170
187
|
// Policy: default to fail_gracefully
|
|
171
188
|
let mockOverflowAction: string = "fail_gracefully";
|
|
@@ -471,7 +488,7 @@ function makeCtx(
|
|
|
471
488
|
toolExecutor,
|
|
472
489
|
});
|
|
473
490
|
|
|
474
|
-
|
|
491
|
+
const ctx = {
|
|
475
492
|
conversationId: "test-conv",
|
|
476
493
|
messages: [
|
|
477
494
|
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
@@ -582,6 +599,8 @@ function makeCtx(
|
|
|
582
599
|
|
|
583
600
|
...ctxOverrides,
|
|
584
601
|
} as unknown as Conversation;
|
|
602
|
+
fakeContextWindowManagers.set(conversationId, ctx.contextWindowManager);
|
|
603
|
+
return ctx;
|
|
585
604
|
}
|
|
586
605
|
|
|
587
606
|
/**
|
|
@@ -28,6 +28,20 @@ let mockUiConfig: { userTimezone?: string; detectedTimezone?: string } = {};
|
|
|
28
28
|
|
|
29
29
|
// ── Module mocks (must precede imports of the module under test) ─────
|
|
30
30
|
|
|
31
|
+
// The real AgentLoop resolves the per-conversation ContextWindowManager from
|
|
32
|
+
// the compaction store keyed by conversationId. These orchestrator tests build
|
|
33
|
+
// fake conversations whose manager is a canned stub, so register each stub in a
|
|
34
|
+
// map the mocked store reads from.
|
|
35
|
+
const fakeContextWindowManagers = new Map<string, unknown>();
|
|
36
|
+
mock.module("../plugins/defaults/compaction/manager-store.js", () => ({
|
|
37
|
+
createContextWindowManager: () => undefined,
|
|
38
|
+
getContextWindowManager: (conversationId: string) =>
|
|
39
|
+
fakeContextWindowManagers.get(conversationId),
|
|
40
|
+
disposeContextWindowManager: (conversationId: string) => {
|
|
41
|
+
fakeContextWindowManagers.delete(conversationId);
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
31
45
|
mock.module("../util/logger.js", () => ({
|
|
32
46
|
getLogger: () =>
|
|
33
47
|
new Proxy({} as Record<string, unknown>, { get: () => () => {} }),
|
|
@@ -100,35 +114,38 @@ mock.module("../context/token-estimator.js", () => ({
|
|
|
100
114
|
let mockReducerStepFn:
|
|
101
115
|
| ((msgs: Message[], cfg: unknown, state: unknown) => unknown)
|
|
102
116
|
| null = null;
|
|
103
|
-
mock.module(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
117
|
+
mock.module(
|
|
118
|
+
"../plugins/defaults/compaction/context-overflow-reducer.js",
|
|
119
|
+
() => ({
|
|
120
|
+
createInitialReducerState: () => ({
|
|
121
|
+
appliedTiers: [],
|
|
122
|
+
injectionMode: "full" as const,
|
|
123
|
+
exhausted: false,
|
|
124
|
+
}),
|
|
125
|
+
reduceContextOverflow: async (
|
|
126
|
+
msgs: Message[],
|
|
127
|
+
cfg: unknown,
|
|
128
|
+
state: unknown,
|
|
129
|
+
) => {
|
|
130
|
+
if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
|
|
131
|
+
return {
|
|
132
|
+
messages: msgs,
|
|
133
|
+
tier: "forced_compaction",
|
|
134
|
+
state: {
|
|
135
|
+
appliedTiers: [
|
|
136
|
+
"forced_compaction",
|
|
137
|
+
"tool_result_truncation",
|
|
138
|
+
"media_stubbing",
|
|
139
|
+
"injection_downgrade",
|
|
140
|
+
],
|
|
141
|
+
injectionMode: "full",
|
|
142
|
+
exhausted: true,
|
|
143
|
+
},
|
|
144
|
+
estimatedTokens: 1000,
|
|
145
|
+
};
|
|
146
|
+
},
|
|
108
147
|
}),
|
|
109
|
-
|
|
110
|
-
msgs: Message[],
|
|
111
|
-
cfg: unknown,
|
|
112
|
-
state: unknown,
|
|
113
|
-
) => {
|
|
114
|
-
if (mockReducerStepFn) return mockReducerStepFn(msgs, cfg, state);
|
|
115
|
-
return {
|
|
116
|
-
messages: msgs,
|
|
117
|
-
tier: "forced_compaction",
|
|
118
|
-
state: {
|
|
119
|
-
appliedTiers: [
|
|
120
|
-
"forced_compaction",
|
|
121
|
-
"tool_result_truncation",
|
|
122
|
-
"media_stubbing",
|
|
123
|
-
"injection_downgrade",
|
|
124
|
-
],
|
|
125
|
-
injectionMode: "full",
|
|
126
|
-
exhausted: true,
|
|
127
|
-
},
|
|
128
|
-
estimatedTokens: 1000,
|
|
129
|
-
};
|
|
130
|
-
},
|
|
131
|
-
}));
|
|
148
|
+
);
|
|
132
149
|
|
|
133
150
|
// Policy: default to fail_gracefully
|
|
134
151
|
let mockOverflowAction: string = "fail_gracefully";
|
|
@@ -520,17 +537,6 @@ mock.module("../memory/llm-request-log-store.js", () => ({
|
|
|
520
537
|
setAgentLoopExitReasonOnLatestLog: setAgentLoopExitReasonOnLatestLogMock,
|
|
521
538
|
}));
|
|
522
539
|
|
|
523
|
-
let mockHasProactiveArtifactCompleted = true;
|
|
524
|
-
let mockTryClaimProactiveArtifactTrigger = false;
|
|
525
|
-
const runProactiveArtifactJobMock = mock(
|
|
526
|
-
async (_params: Record<string, unknown>) => {},
|
|
527
|
-
);
|
|
528
|
-
mock.module("../proactive-artifact/index.js", () => ({
|
|
529
|
-
hasProactiveArtifactCompleted: () => mockHasProactiveArtifactCompleted,
|
|
530
|
-
tryClaimProactiveArtifactTrigger: () => mockTryClaimProactiveArtifactTrigger,
|
|
531
|
-
runProactiveArtifactJob: runProactiveArtifactJobMock,
|
|
532
|
-
}));
|
|
533
|
-
|
|
534
540
|
// ── Imports (after mocks) ────────────────────────────────────────────
|
|
535
541
|
|
|
536
542
|
import { AgentLoop } from "../agent/loop.js";
|
|
@@ -594,7 +600,7 @@ function makeCtx(
|
|
|
594
600
|
toolExecutor,
|
|
595
601
|
});
|
|
596
602
|
|
|
597
|
-
|
|
603
|
+
const ctx = {
|
|
598
604
|
conversationId: "test-conv",
|
|
599
605
|
messages: [
|
|
600
606
|
{ role: "user", content: [{ type: "text", text: "Hello" }] },
|
|
@@ -710,6 +716,8 @@ function makeCtx(
|
|
|
710
716
|
|
|
711
717
|
...ctxOverrides,
|
|
712
718
|
} as unknown as Conversation;
|
|
719
|
+
fakeContextWindowManagers.set(conversationId, ctx.contextWindowManager);
|
|
720
|
+
return ctx;
|
|
713
721
|
}
|
|
714
722
|
|
|
715
723
|
type CompactionResult = Parameters<typeof applyCompactionResult>[1];
|
|
@@ -765,9 +773,6 @@ beforeEach(() => {
|
|
|
765
773
|
title: null,
|
|
766
774
|
};
|
|
767
775
|
mockMessageById = null;
|
|
768
|
-
mockHasProactiveArtifactCompleted = true;
|
|
769
|
-
mockTryClaimProactiveArtifactTrigger = false;
|
|
770
|
-
runProactiveArtifactJobMock.mockClear();
|
|
771
776
|
setConversationHistoryStrippedAtMock.mockClear();
|
|
772
777
|
setConversationHistoryStrippedAtMock.mockImplementation(() => {});
|
|
773
778
|
applyRuntimeInjectionsMock.mockClear();
|
|
@@ -862,59 +867,6 @@ describe("session-agent-loop", () => {
|
|
|
862
867
|
});
|
|
863
868
|
});
|
|
864
869
|
|
|
865
|
-
describe("proactive artifact trigger", () => {
|
|
866
|
-
test("does not start proactive artifact jobs after foreground user turns", async () => {
|
|
867
|
-
mockConversationRow = {
|
|
868
|
-
...mockConversationRow,
|
|
869
|
-
id: "test-conv",
|
|
870
|
-
conversationType: "standard",
|
|
871
|
-
};
|
|
872
|
-
mockMessageById = {
|
|
873
|
-
id: "user-msg-1",
|
|
874
|
-
conversationId: "test-conv",
|
|
875
|
-
createdAt: 1000,
|
|
876
|
-
};
|
|
877
|
-
mockHasProactiveArtifactCompleted = false;
|
|
878
|
-
mockTryClaimProactiveArtifactTrigger = true;
|
|
879
|
-
|
|
880
|
-
// A two-call agent turn: the model invokes `app_create`, then wraps up
|
|
881
|
-
// with a final text reply.
|
|
882
|
-
const ctx = makeCtx({
|
|
883
|
-
conversationId: "test-conv",
|
|
884
|
-
providerResponses: [
|
|
885
|
-
{
|
|
886
|
-
content: [
|
|
887
|
-
{ type: "text", text: "I'll build that app." },
|
|
888
|
-
{
|
|
889
|
-
type: "tool_use",
|
|
890
|
-
id: "tool-1",
|
|
891
|
-
name: "app_create",
|
|
892
|
-
input: { name: "Flow" },
|
|
893
|
-
},
|
|
894
|
-
],
|
|
895
|
-
model: "mock-model",
|
|
896
|
-
usage: { inputTokens: 10, outputTokens: 5 },
|
|
897
|
-
stopReason: "tool_use",
|
|
898
|
-
},
|
|
899
|
-
textResponse("Done."),
|
|
900
|
-
],
|
|
901
|
-
toolExecutor: async () => ({ content: "{}", isError: false }),
|
|
902
|
-
});
|
|
903
|
-
await runAgentLoopImpl(
|
|
904
|
-
ctx,
|
|
905
|
-
"build a kanban app",
|
|
906
|
-
"user-msg-1",
|
|
907
|
-
() => {},
|
|
908
|
-
{
|
|
909
|
-
isUserMessage: true,
|
|
910
|
-
},
|
|
911
|
-
);
|
|
912
|
-
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
913
|
-
|
|
914
|
-
expect(runProactiveArtifactJobMock).toHaveBeenCalledTimes(0);
|
|
915
|
-
});
|
|
916
|
-
});
|
|
917
|
-
|
|
918
870
|
describe("disk pressure injection context", () => {
|
|
919
871
|
// The loop sets `ctx.diskPressureCleanupModeActive` for the duration of the
|
|
920
872
|
// turn (the disk-pressure-warning injector reads it via the per-conversation
|
|
@@ -807,7 +807,7 @@ describe("web_search_tool_result structural guard", () => {
|
|
|
807
807
|
// Media token counting iterates tool_result.contentBlocks for nested
|
|
808
808
|
// image/file blocks. web_search_tool_result has opaque content with no
|
|
809
809
|
// contentBlocks property, so it cannot contain nested media.
|
|
810
|
-
"
|
|
810
|
+
"plugins/defaults/compaction/context-overflow-reducer.ts",
|
|
811
811
|
|
|
812
812
|
// Final orphan-pair safety pass in the Slack transcript renderer.
|
|
813
813
|
// Server-side block types (`server_tool_use`, `web_search_tool_result`)
|
|
@@ -100,56 +100,59 @@ mock.module("../context/token-estimator.js", () => ({
|
|
|
100
100
|
// but these tests exercise the Conversation-level flow, not the reducer internals.
|
|
101
101
|
// The reducer mock delegates to the compactFn to simulate a forced compaction
|
|
102
102
|
// tier, matching the real reducer's behavior for Tier 1.
|
|
103
|
-
mock.module(
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
if (
|
|
131
|
-
|
|
132
|
-
|
|
103
|
+
mock.module(
|
|
104
|
+
"../plugins/defaults/compaction/context-overflow-reducer.js",
|
|
105
|
+
() => ({
|
|
106
|
+
createInitialReducerState: () => ({
|
|
107
|
+
appliedTiers: [],
|
|
108
|
+
injectionMode: "full" as const,
|
|
109
|
+
exhausted: false,
|
|
110
|
+
}),
|
|
111
|
+
reduceContextOverflow: async (
|
|
112
|
+
msgs: Message[],
|
|
113
|
+
_cfg: unknown,
|
|
114
|
+
_state: unknown,
|
|
115
|
+
compactFn?: (
|
|
116
|
+
m: Message[],
|
|
117
|
+
s: AbortSignal | undefined,
|
|
118
|
+
o: Record<string, unknown>,
|
|
119
|
+
) => Promise<{
|
|
120
|
+
compacted: boolean;
|
|
121
|
+
messages: Message[];
|
|
122
|
+
compactedPersistedMessages?: number;
|
|
123
|
+
summaryText?: string;
|
|
124
|
+
[k: string]: unknown;
|
|
125
|
+
}>,
|
|
126
|
+
signal?: AbortSignal,
|
|
127
|
+
) => {
|
|
128
|
+
let resultMessages = msgs;
|
|
129
|
+
let compactionResult;
|
|
130
|
+
if (compactFn) {
|
|
131
|
+
const cr = await compactFn(msgs, signal, { force: true });
|
|
132
|
+
if (cr.compacted) {
|
|
133
|
+
resultMessages = cr.messages;
|
|
134
|
+
compactionResult = cr;
|
|
135
|
+
}
|
|
133
136
|
}
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
},
|
|
152
|
-
|
|
137
|
+
return {
|
|
138
|
+
messages: resultMessages,
|
|
139
|
+
tier: "forced_compaction",
|
|
140
|
+
state: {
|
|
141
|
+
appliedTiers: [
|
|
142
|
+
"forced_compaction",
|
|
143
|
+
"tool_result_truncation",
|
|
144
|
+
"media_stubbing",
|
|
145
|
+
"injection_downgrade",
|
|
146
|
+
],
|
|
147
|
+
injectionMode: "full",
|
|
148
|
+
exhausted: true,
|
|
149
|
+
},
|
|
150
|
+
estimatedTokens: 1000,
|
|
151
|
+
compactionResult,
|
|
152
|
+
};
|
|
153
|
+
},
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
153
156
|
|
|
154
157
|
mock.module("../daemon/context-overflow-policy.js", () => ({
|
|
155
158
|
resolveOverflowAction: () => "fail_gracefully",
|
|
@@ -6,10 +6,11 @@
|
|
|
6
6
|
* metadata, recall log, `memory_recalled` event), trust gating, the runtime
|
|
7
7
|
* injection the hook applies for every actor (and the assembled-block persist),
|
|
8
8
|
* error propagation, and abort-signal forwarding. Uses `mock.module` to stub
|
|
9
|
-
* the persistence helpers
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* hook
|
|
9
|
+
* the persistence helpers, `applyRuntimeInjections`, and the conversation
|
|
10
|
+
* registry / trust resolver so the test doesn't touch the developer's real
|
|
11
|
+
* `~/.vellum`, database, or live conversation state. The memory graph handle,
|
|
12
|
+
* abort signal, and trust class are self-resolved by the hook from a fake
|
|
13
|
+
* conversation installed in the registry mock.
|
|
13
14
|
*/
|
|
14
15
|
|
|
15
16
|
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
@@ -37,7 +38,25 @@ mock.module("../daemon/conversation-runtime-assembly.js", () => ({
|
|
|
37
38
|
applyRuntimeInjections: applyRuntimeInjectionsMock,
|
|
38
39
|
}));
|
|
39
40
|
|
|
41
|
+
// The hook self-resolves the live conversation and its trust class; both are
|
|
42
|
+
// driven from these refs so each test controls what the hook sees.
|
|
43
|
+
let currentConversation: Conversation | undefined;
|
|
44
|
+
let currentTrustClass: "guardian" | "unknown" = "guardian";
|
|
45
|
+
const findConversationOrSubagentMock = mock(
|
|
46
|
+
(_conversationId?: string) => currentConversation,
|
|
47
|
+
);
|
|
48
|
+
mock.module("../daemon/conversation-registry.js", () => ({
|
|
49
|
+
findConversationOrSubagent: findConversationOrSubagentMock,
|
|
50
|
+
}));
|
|
51
|
+
mock.module("../daemon/trust-context.js", () => ({
|
|
52
|
+
resolveTrustClass: () => currentTrustClass,
|
|
53
|
+
}));
|
|
54
|
+
mock.module("../config/loader.js", () => ({
|
|
55
|
+
getConfig: () => ({}) as AssistantConfig,
|
|
56
|
+
}));
|
|
57
|
+
|
|
40
58
|
import type { AssistantConfig } from "../config/schema.js";
|
|
59
|
+
import type { Conversation } from "../daemon/conversation.js";
|
|
41
60
|
import type { ServerMessage } from "../daemon/message-protocol.js";
|
|
42
61
|
import type { ConversationGraphMemory } from "../memory/graph/conversation-graph-memory.js";
|
|
43
62
|
import type { QdrantSparseVector } from "../memory/qdrant-client.js";
|
|
@@ -118,18 +137,13 @@ function makeFakeGraphMemory(overrides?: {
|
|
|
118
137
|
function makeHookCtx(
|
|
119
138
|
overrides: Partial<MemoryRetrievalHookContext> = {},
|
|
120
139
|
): MemoryRetrievalHookContext {
|
|
121
|
-
const { memory } = makeFakeGraphMemory();
|
|
122
140
|
return {
|
|
123
|
-
graphMemory: memory,
|
|
124
|
-
config: {} as AssistantConfig,
|
|
125
141
|
onEvent: () => {},
|
|
126
|
-
isTrustedActor: true,
|
|
127
142
|
conversationId: "conv-test",
|
|
128
143
|
userMessageId: "msg-test",
|
|
129
144
|
logger: {
|
|
130
145
|
warn: () => {},
|
|
131
146
|
} as unknown as MemoryRetrievalHookContext["logger"],
|
|
132
|
-
signal: new AbortController().signal,
|
|
133
147
|
latestMessages: [],
|
|
134
148
|
requestId: "req-test",
|
|
135
149
|
mode: "full",
|
|
@@ -140,10 +154,30 @@ function makeHookCtx(
|
|
|
140
154
|
};
|
|
141
155
|
}
|
|
142
156
|
|
|
157
|
+
/**
|
|
158
|
+
* Install a fake live conversation for the hook to self-resolve by id: the
|
|
159
|
+
* graph handle, abort signal, and trust class all come from here rather than
|
|
160
|
+
* the hook context.
|
|
161
|
+
*/
|
|
162
|
+
function installConversation(
|
|
163
|
+
graphMemory: ConversationGraphMemory,
|
|
164
|
+
opts?: { trusted?: boolean; signal?: AbortSignal },
|
|
165
|
+
): void {
|
|
166
|
+
currentTrustClass = opts?.trusted === false ? "unknown" : "guardian";
|
|
167
|
+
currentConversation = {
|
|
168
|
+
graphMemory,
|
|
169
|
+
trustContext: undefined,
|
|
170
|
+
abortController: { signal: opts?.signal ?? new AbortController().signal },
|
|
171
|
+
} as unknown as Conversation;
|
|
172
|
+
}
|
|
173
|
+
|
|
143
174
|
beforeEach(() => {
|
|
144
175
|
updateMessageMetadataMock.mockReset();
|
|
145
176
|
recordMemoryRecallLogMock.mockReset();
|
|
146
177
|
applyRuntimeInjectionsMock.mockClear();
|
|
178
|
+
findConversationOrSubagentMock.mockClear();
|
|
179
|
+
currentConversation = undefined;
|
|
180
|
+
currentTrustClass = "guardian";
|
|
147
181
|
});
|
|
148
182
|
|
|
149
183
|
describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
@@ -154,7 +188,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
154
188
|
const { memory, prepareMemoryMock } = makeFakeGraphMemory({
|
|
155
189
|
messages: injected,
|
|
156
190
|
});
|
|
157
|
-
|
|
191
|
+
installConversation(memory, { trusted: true });
|
|
192
|
+
const ctx = makeHookCtx();
|
|
158
193
|
|
|
159
194
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
160
195
|
|
|
@@ -176,7 +211,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
176
211
|
userQueryVector: userDense,
|
|
177
212
|
userQuerySparseVector: userSparse,
|
|
178
213
|
});
|
|
179
|
-
|
|
214
|
+
installConversation(withUserQuery.memory, { trusted: true });
|
|
215
|
+
const userCtx = makeHookCtx();
|
|
180
216
|
await userPromptSubmitMemoryRetrieval(userCtx);
|
|
181
217
|
// User-query pair wins — never crossed with the summary signal — and is
|
|
182
218
|
// recorded back onto the graph handle for the PKB-reminder injector.
|
|
@@ -189,7 +225,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
189
225
|
queryVector: summaryDense,
|
|
190
226
|
sparseVector: summarySparse,
|
|
191
227
|
});
|
|
192
|
-
|
|
228
|
+
installConversation(summaryOnly.memory, { trusted: true });
|
|
229
|
+
const summaryCtx = makeHookCtx();
|
|
193
230
|
await userPromptSubmitMemoryRetrieval(summaryCtx);
|
|
194
231
|
expect(summaryOnly.recordPkbQueryVectorsMock).toHaveBeenCalledWith(
|
|
195
232
|
summaryDense,
|
|
@@ -209,7 +246,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
209
246
|
messages: injected,
|
|
210
247
|
blocks: { unifiedTurnContext: "tc-block" },
|
|
211
248
|
}));
|
|
212
|
-
|
|
249
|
+
installConversation(memory, { trusted: true });
|
|
250
|
+
const ctx = makeHookCtx({ userMessageId: "msg-77" });
|
|
213
251
|
|
|
214
252
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
215
253
|
|
|
@@ -229,11 +267,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
229
267
|
const seeded: Message[] = [
|
|
230
268
|
{ role: "user", content: [{ type: "text", text: "seeded" }] },
|
|
231
269
|
];
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
isTrustedActor: false,
|
|
235
|
-
latestMessages: seeded,
|
|
236
|
-
});
|
|
270
|
+
installConversation(memory, { trusted: false });
|
|
271
|
+
const ctx = makeHookCtx({ latestMessages: seeded });
|
|
237
272
|
|
|
238
273
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
239
274
|
|
|
@@ -250,11 +285,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
250
285
|
const seeded: Message[] = [
|
|
251
286
|
{ role: "user", content: [{ type: "text", text: "seeded" }] },
|
|
252
287
|
];
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
isTrustedActor: false,
|
|
256
|
-
latestMessages: seeded,
|
|
257
|
-
});
|
|
288
|
+
installConversation(memory, { trusted: false });
|
|
289
|
+
const ctx = makeHookCtx({ latestMessages: seeded });
|
|
258
290
|
|
|
259
291
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
260
292
|
|
|
@@ -273,8 +305,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
273
305
|
injectedBlockText: "injected-block",
|
|
274
306
|
metrics: makeMetrics(),
|
|
275
307
|
});
|
|
308
|
+
installConversation(memory, { trusted: true });
|
|
276
309
|
const ctx = makeHookCtx({
|
|
277
|
-
graphMemory: memory,
|
|
278
310
|
onEvent: (msg) => received.push(msg),
|
|
279
311
|
userMessageId: "msg-42",
|
|
280
312
|
conversationId: "conv-42",
|
|
@@ -298,7 +330,8 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
298
330
|
|
|
299
331
|
test("skips metadata persist when no block text is injected", async () => {
|
|
300
332
|
const { memory } = makeFakeGraphMemory({ injectedBlockText: null });
|
|
301
|
-
|
|
333
|
+
installConversation(memory, { trusted: true });
|
|
334
|
+
const ctx = makeHookCtx();
|
|
302
335
|
|
|
303
336
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
304
337
|
|
|
@@ -321,16 +354,17 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
321
354
|
const graphMemory = {
|
|
322
355
|
prepareMemory: failingPrepare,
|
|
323
356
|
} as unknown as ConversationGraphMemory;
|
|
324
|
-
|
|
357
|
+
installConversation(graphMemory, { trusted: true });
|
|
358
|
+
const ctx = makeHookCtx();
|
|
325
359
|
|
|
326
360
|
await expect(userPromptSubmitMemoryRetrieval(ctx)).rejects.toThrow(
|
|
327
361
|
"retrieval failed",
|
|
328
362
|
);
|
|
329
363
|
});
|
|
330
364
|
|
|
331
|
-
test("forwards the
|
|
332
|
-
// The hook hands
|
|
333
|
-
// external cancel aborts the underlying retrieval.
|
|
365
|
+
test("forwards the conversation abort signal into prepareMemory", async () => {
|
|
366
|
+
// The hook hands the live conversation's abort signal straight to
|
|
367
|
+
// `prepareMemory` so an external cancel aborts the underlying retrieval.
|
|
334
368
|
let capturedSignal: AbortSignal | undefined;
|
|
335
369
|
const prepareMemoryMock = mock(
|
|
336
370
|
async (
|
|
@@ -355,7 +389,11 @@ describe("user-prompt-submit-temp hook (memory retrieval)", () => {
|
|
|
355
389
|
recordPkbQueryVectors: mock(() => {}),
|
|
356
390
|
} as unknown as ConversationGraphMemory;
|
|
357
391
|
const controller = new AbortController();
|
|
358
|
-
|
|
392
|
+
installConversation(graphMemory, {
|
|
393
|
+
trusted: true,
|
|
394
|
+
signal: controller.signal,
|
|
395
|
+
});
|
|
396
|
+
const ctx = makeHookCtx();
|
|
359
397
|
|
|
360
398
|
await userPromptSubmitMemoryRetrieval(ctx);
|
|
361
399
|
|