@vellumai/assistant 0.12.2-dev.202609170320.2f82e57 → 0.12.2-dev.202609170629.61d51c8
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/architecture/memory.md +6 -0
- package/docs/desktop-browser-cli.md +2 -0
- package/package.json +1 -1
- package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
- package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
- package/src/__tests__/db-conversation-tool-surface.test.ts +95 -18
- package/src/__tests__/subagent-tool-gate-mode.test.ts +63 -6
- package/src/browser/virtual-desktop-target.ts +1 -2
- package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
- package/src/config/schemas/memory-v3.ts +12 -0
- package/src/daemon/conversation-tool-setup.ts +20 -8
- package/src/daemon/conversation.ts +56 -18
- package/src/daemon/wake-conversation-ops.ts +12 -0
- package/src/desktop/desktop-automation-lease.ts +1 -2
- package/src/desktop/desktop-help.ts +1 -2
- package/src/desktop/desktop-stream-bridge.test.ts +65 -10
- package/src/desktop/desktop-stream-bridge.ts +3 -3
- package/src/desktop/virtual-desktop-feature.ts +1 -1
- package/src/persistence/conversation-plugin-facade.ts +21 -4
- package/src/persistence/conversation-tool-surface.ts +72 -23
- package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
- package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
- package/src/persistence/schema/conversation-tool-surfaces.ts +14 -4
- package/src/persistence/steps.ts +9 -0
- package/src/plugin-api/index.ts +2 -0
- package/src/plugins/defaults/memory/AGENTS.md +25 -3
- package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
- package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +38 -10
- package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
- package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +23 -8
- package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
- package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +82 -2
- package/src/plugins/defaults/memory/v3/orchestrate.ts +30 -7
- package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
- package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
- package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
- package/src/plugins/defaults/memory/v3/pool-select.ts +21 -12
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +31 -13
- package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
- package/src/prompts/delegation-gate.ts +57 -0
- package/src/prompts/system-prompt.ts +14 -32
- package/src/runtime/__tests__/agent-wake.test.ts +11 -3
- package/src/runtime/agent-wake.ts +12 -1
- package/src/runtime/http-server.ts +2 -2
- package/src/runtime/routes/desktop-setup-routes.ts +1 -2
|
@@ -270,6 +270,12 @@ under `memory.v3.*`. The `memory.v2.enabled` flag gates only the v2 injection
|
|
|
270
270
|
engine's turn-time selection; the substrate runs whenever
|
|
271
271
|
`usesConceptPageMemory()` holds.
|
|
272
272
|
|
|
273
|
+
`memory.v3.poolLog.captureInput` (off by default) makes the v3 selector
|
|
274
|
+
persist its exact per-turn input beside the always-written pool audit
|
|
275
|
+
(`memory_v3_pool_inputs`, `memory_v3_pool_texts`), for offline selector
|
|
276
|
+
evaluation and training data; the memory plugin `AGENTS.md` documents the
|
|
277
|
+
tables.
|
|
278
|
+
|
|
273
279
|
`src/plugins/defaults/memory/AGENTS.md` carries the details that matter when
|
|
274
280
|
you touch this: the three substrate keys whose names differ from their v2 twin,
|
|
275
281
|
the three disjoint places the dense/sparse sum-to-1 invariant is checked, and
|
|
@@ -33,3 +33,5 @@ The client records key and mouse presses before dispatch. On release it opens a
|
|
|
33
33
|
Validation: focused client tests exercise shared snapshot/click behavior, namespace isolation, stale references, target changes, cancellation and uncertain-input cleanup. Lease tests cover browser ownership, cancellation and cleanup independently of native input. The Linux smoke script exercises real Chrome, the CLI and visible pointer feedback.
|
|
34
34
|
|
|
35
35
|
The desktop header icon pulses in the assistant's avatar accent color while a browser automation lease is active, including between browser commands. It shares the progress indicator's accent and neutral fallback. Reduced-motion clients show a solid accent. Setup status exposes the optional `automationActive` field; `desktop_activity_changed` events refresh it on acquisition and cancellation or release. Installation progress retains its `assistant:self:desktop` sync invalidations. The indicator reads status without starting installation, and reconnects refetch the current lease state.
|
|
36
|
+
|
|
37
|
+
Desktop streaming checks the current in-memory gateway feature flags during connection startup. Once connected, frame and drain callbacks do not check feature flags or load workspace configuration. Turning the flag off prevents new connections; an existing stream continues until it closes or the desktop stops.
|
package/package.json
CHANGED
|
@@ -74,6 +74,7 @@ function makeTarget(onRun: (conv: Conversation) => void): {
|
|
|
74
74
|
subagentAllowedTools: undefined as ReadonlySet<string> | undefined,
|
|
75
75
|
subagentToolGateMode: undefined as string | undefined,
|
|
76
76
|
toolContextPin: undefined,
|
|
77
|
+
delegateIndependentTasksReplay: undefined as boolean | undefined,
|
|
77
78
|
preactivatedSkillIds: undefined as readonly string[] | undefined,
|
|
78
79
|
setSubagentAllowedTools: (tools?: ReadonlySet<string>) => {
|
|
79
80
|
target.subagentAllowedTools = tools;
|
|
@@ -82,9 +83,12 @@ function makeTarget(onRun: (conv: Conversation) => void): {
|
|
|
82
83
|
target.preactivatedSkillIds = ids;
|
|
83
84
|
},
|
|
84
85
|
// The section's gate, rendered as a marker so the assertions turn on the
|
|
85
|
-
// real predicate rather than on a stubbed answer.
|
|
86
|
+
// real predicate rather than on a stubbed answer. Mirrors
|
|
87
|
+
// `Conversation.buildCurrentSystemPrompt`: a replayed rendered state wins
|
|
88
|
+
// over the derivation.
|
|
86
89
|
buildCurrentSystemPrompt: () =>
|
|
87
|
-
|
|
90
|
+
(target.delegateIndependentTasksReplay ??
|
|
91
|
+
canSpawnSubagentsForTurn(target as unknown as Conversation))
|
|
88
92
|
? `base ${DELEGATION_SECTION}`
|
|
89
93
|
: "base",
|
|
90
94
|
// Mirrors `Conversation.syncLoopSystemPrompt`.
|
|
@@ -156,6 +160,64 @@ describe("the delegation section on a direct wake", () => {
|
|
|
156
160
|
expect(promptDuringRun).toContain(DELEGATION_SECTION);
|
|
157
161
|
});
|
|
158
162
|
|
|
163
|
+
test("a replaying wake renders the section its source rendered", async () => {
|
|
164
|
+
let promptDuringRun = "";
|
|
165
|
+
const { target, loopPrompt } = makeTarget(() => {
|
|
166
|
+
promptDuringRun = loopPrompt();
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
await wakeAgentForOpportunity(
|
|
170
|
+
{
|
|
171
|
+
conversationId: target.conversationId,
|
|
172
|
+
hint: "test hint",
|
|
173
|
+
source: "scheduler",
|
|
174
|
+
// The memory retrospective's shape: an allowlist with no spawn path,
|
|
175
|
+
// gated at execution...
|
|
176
|
+
allowedTools: ["remember", "skill_load"],
|
|
177
|
+
toolGateMode: "execution",
|
|
178
|
+
// ...replaying an interactive source whose live turn rendered the
|
|
179
|
+
// section.
|
|
180
|
+
wireToolDefinitions: [
|
|
181
|
+
{ name: "remember", description: "Save", input_schema: {} },
|
|
182
|
+
],
|
|
183
|
+
delegateIndependentTasks: true,
|
|
184
|
+
},
|
|
185
|
+
{ resolveTarget: async () => target },
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
expect(promptDuringRun).toContain(DELEGATION_SECTION);
|
|
189
|
+
// The replay comes off with the rest of the wake's scope.
|
|
190
|
+
expect(target.delegateIndependentTasksReplay).toBeUndefined();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
test("a replaying wake renders the section off when its source did", async () => {
|
|
194
|
+
// A channel-delivered source records the section off. Replaying that
|
|
195
|
+
// keeps the fork's prompt in parity even though this wake's own scope
|
|
196
|
+
// could spawn.
|
|
197
|
+
let promptDuringRun = "";
|
|
198
|
+
const { target, loopPrompt } = makeTarget(() => {
|
|
199
|
+
promptDuringRun = loopPrompt();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await wakeAgentForOpportunity(
|
|
203
|
+
{
|
|
204
|
+
conversationId: target.conversationId,
|
|
205
|
+
hint: "test hint",
|
|
206
|
+
source: "scheduler",
|
|
207
|
+
allowedTools: ["skill_load", "skill_execute", "subagent_spawn"],
|
|
208
|
+
toolGateMode: "execution",
|
|
209
|
+
wireToolDefinitions: [
|
|
210
|
+
{ name: "skill_load", description: "Load", input_schema: {} },
|
|
211
|
+
],
|
|
212
|
+
delegateIndependentTasks: false,
|
|
213
|
+
},
|
|
214
|
+
{ resolveTarget: async () => target },
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
expect(promptDuringRun).not.toContain(DELEGATION_SECTION);
|
|
218
|
+
expect(promptDuringRun.length).toBeGreaterThan(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
159
221
|
test("the restored prompt is built without the wake's persona", async () => {
|
|
160
222
|
// `wakePersonaOverride` feeds `buildCurrentSystemPrompt`, so a rebuild
|
|
161
223
|
// that ran before the clear would leave the loop holding the wake's
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `Conversation.buildCurrentSystemPrompt` captures the delegation-section
|
|
3
|
+
* state the prompt it builds renders (`renderedDelegateIndependentTasks`),
|
|
4
|
+
* which the wire-surface recorder persists for a retrospective fork to
|
|
5
|
+
* replay. The loop sends the prompt built before the run and never rebuilds
|
|
6
|
+
* it mid-run, so the captured state has to be the one that build used: not a
|
|
7
|
+
* re-derivation at send time, and not the state of a build that failed while
|
|
8
|
+
* the loop kept its previous prompt.
|
|
9
|
+
*/
|
|
10
|
+
import { beforeEach, describe, expect, mock, test } from "bun:test";
|
|
11
|
+
|
|
12
|
+
import { CompactionCircuit } from "../agent/compaction-circuit.js";
|
|
13
|
+
import type { AgentEvent } from "../agent/loop.js";
|
|
14
|
+
import type { AssistantEvent } from "../api/index.js";
|
|
15
|
+
import type { Message, ProviderResponse } from "../providers/types.js";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Mocks — must precede Conversation import
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
mock.module("../providers/registry.js", () => ({
|
|
22
|
+
getProvider: () => ({ name: "mock-provider" }),
|
|
23
|
+
initializeProviders: async () => {},
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
mock.module("../config/assistant-feature-flags.js", () => ({
|
|
27
|
+
isAssistantFeatureFlagEnabled: () => true,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
// The builder under the conversation's control: records the options each
|
|
31
|
+
// build receives and can be made to throw, which is what a wake's prompt
|
|
32
|
+
// sync swallows before running on the previous prompt.
|
|
33
|
+
let builtWith: Array<Record<string, unknown> | undefined> = [];
|
|
34
|
+
let throwOnBuild = false;
|
|
35
|
+
mock.module("../prompts/system-prompt.js", () => ({
|
|
36
|
+
buildSystemPrompt: (options?: Record<string, unknown>) => {
|
|
37
|
+
if (throwOnBuild) {
|
|
38
|
+
throw new Error("prompt build failed");
|
|
39
|
+
}
|
|
40
|
+
builtWith.push(options);
|
|
41
|
+
return "system prompt";
|
|
42
|
+
},
|
|
43
|
+
}));
|
|
44
|
+
|
|
45
|
+
mock.module("../config/skills.js", () => ({
|
|
46
|
+
loadSkillCatalog: () => [],
|
|
47
|
+
loadSkillBySelector: () => ({ skill: null }),
|
|
48
|
+
ensureSkillIcon: async () => null,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
mock.module("../config/skill-state.js", () => ({
|
|
52
|
+
resolveSkillStates: () => [],
|
|
53
|
+
}));
|
|
54
|
+
|
|
55
|
+
mock.module("../permissions/trust-store.js", () => ({
|
|
56
|
+
addRule: () => {},
|
|
57
|
+
findHighestPriorityRule: () => null,
|
|
58
|
+
clearCache: () => {},
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
mock.module("../security/secret-allowlist.js", () => ({
|
|
62
|
+
resetAllowlist: () => {},
|
|
63
|
+
}));
|
|
64
|
+
|
|
65
|
+
mock.module("../persistence/conversation-crud.js", () => ({
|
|
66
|
+
setConversationProcessingStartedAt: () => {},
|
|
67
|
+
isConversationProcessing: () => false,
|
|
68
|
+
setConversationOriginChannelIfUnset: () => {},
|
|
69
|
+
updateConversationContextWindow: () => {},
|
|
70
|
+
deleteMessageById: () => {},
|
|
71
|
+
provenanceFromTrustContext: () => ({
|
|
72
|
+
source: "user",
|
|
73
|
+
trustContext: undefined,
|
|
74
|
+
}),
|
|
75
|
+
getConversationOriginInterface: () => null,
|
|
76
|
+
getConversationOriginChannel: () => null,
|
|
77
|
+
getMessages: () => [],
|
|
78
|
+
getConversation: () => ({
|
|
79
|
+
id: "conv-1",
|
|
80
|
+
contextSummary: null,
|
|
81
|
+
contextCompactedMessageCount: 0,
|
|
82
|
+
totalInputTokens: 0,
|
|
83
|
+
totalOutputTokens: 0,
|
|
84
|
+
totalEstimatedCost: 0,
|
|
85
|
+
}),
|
|
86
|
+
createConversation: () => ({ id: "conv-1" }),
|
|
87
|
+
addMessage: () => ({ id: `msg-${Date.now()}` }),
|
|
88
|
+
updateConversationUsage: () => {},
|
|
89
|
+
updateConversationTitle: () => {},
|
|
90
|
+
reserveMessage: mock(async () => ({ id: "msg-reserve" })),
|
|
91
|
+
}));
|
|
92
|
+
|
|
93
|
+
mock.module("../persistence/conversation-queries.js", () => ({
|
|
94
|
+
listConversations: () => [],
|
|
95
|
+
}));
|
|
96
|
+
|
|
97
|
+
mock.module("../persistence/attachments-store.js", () => ({
|
|
98
|
+
uploadAttachment: () => ({ id: `att-${Date.now()}` }),
|
|
99
|
+
linkAttachmentToMessage: () => {},
|
|
100
|
+
}));
|
|
101
|
+
|
|
102
|
+
mock.module("../plugins/defaults/compaction/window-manager.js", () => ({
|
|
103
|
+
ContextWindowManager: class {
|
|
104
|
+
estimateInputTokens() {
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
get tokenCountInputs() {
|
|
108
|
+
return { systemPrompt: "", tools: undefined };
|
|
109
|
+
}
|
|
110
|
+
constructor() {}
|
|
111
|
+
updateConfig() {}
|
|
112
|
+
shouldCompact() {
|
|
113
|
+
return { needed: false, estimatedTokens: 0 };
|
|
114
|
+
}
|
|
115
|
+
async maybeCompact() {
|
|
116
|
+
return { compacted: false };
|
|
117
|
+
}
|
|
118
|
+
resetOverflowRecovery() {}
|
|
119
|
+
},
|
|
120
|
+
createContextSummaryMessage: () => ({
|
|
121
|
+
role: "user",
|
|
122
|
+
content: [{ type: "text", text: "summary" }],
|
|
123
|
+
}),
|
|
124
|
+
getSummaryFromContextMessage: () => null,
|
|
125
|
+
}));
|
|
126
|
+
|
|
127
|
+
mock.module("../persistence/llm-usage-store.js", () => ({
|
|
128
|
+
recordUsageEvent: () => ({ id: "mock-id", createdAt: Date.now() }),
|
|
129
|
+
listUsageEvents: () => [],
|
|
130
|
+
}));
|
|
131
|
+
|
|
132
|
+
mock.module("../agent/loop.js", () => ({
|
|
133
|
+
AgentLoop: class {
|
|
134
|
+
compactionCircuit = new CompactionCircuit("test-conv");
|
|
135
|
+
constructor() {}
|
|
136
|
+
getToolTokenBudget() {
|
|
137
|
+
return 0;
|
|
138
|
+
}
|
|
139
|
+
getResolvedTools() {
|
|
140
|
+
return [];
|
|
141
|
+
}
|
|
142
|
+
getActiveModel() {
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
setSystemPrompt() {}
|
|
146
|
+
async run(_options: {
|
|
147
|
+
messages: Message[];
|
|
148
|
+
onEvent: (event: AgentEvent) => void;
|
|
149
|
+
}): Promise<Message[]> {
|
|
150
|
+
return [];
|
|
151
|
+
}
|
|
152
|
+
},
|
|
153
|
+
}));
|
|
154
|
+
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
// Import Conversation AFTER mocks
|
|
157
|
+
// ---------------------------------------------------------------------------
|
|
158
|
+
|
|
159
|
+
import { Conversation } from "../daemon/conversation.js";
|
|
160
|
+
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// Helpers
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
|
|
165
|
+
function makeProvider() {
|
|
166
|
+
return {
|
|
167
|
+
name: "mock",
|
|
168
|
+
async sendMessage(): Promise<ProviderResponse> {
|
|
169
|
+
return {
|
|
170
|
+
content: [],
|
|
171
|
+
model: "mock",
|
|
172
|
+
usage: { inputTokens: 0, outputTokens: 0 },
|
|
173
|
+
stopReason: "end_turn",
|
|
174
|
+
};
|
|
175
|
+
},
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
let conversationSeq = 0;
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* A conversation whose prompt is built, or, given a prompt other than the
|
|
183
|
+
* builder's, one running on a verbatim system-prompt override.
|
|
184
|
+
*/
|
|
185
|
+
function makeConversation(systemPrompt = "system prompt"): Conversation {
|
|
186
|
+
conversationSeq += 1;
|
|
187
|
+
return new Conversation(
|
|
188
|
+
`conv-rendered-delegation-${conversationSeq}`,
|
|
189
|
+
makeProvider(),
|
|
190
|
+
systemPrompt,
|
|
191
|
+
(_msg: AssistantEvent) => {},
|
|
192
|
+
"/tmp",
|
|
193
|
+
{ maxTokens: 4096 },
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function lastBuildDelegation(): unknown {
|
|
198
|
+
return builtWith.at(-1)?.delegateIndependentTasks;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
beforeEach(() => {
|
|
202
|
+
builtWith = [];
|
|
203
|
+
throwOnBuild = false;
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
// ---------------------------------------------------------------------------
|
|
207
|
+
// Tests
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
|
|
210
|
+
describe("Conversation.buildCurrentSystemPrompt delegation-state capture", () => {
|
|
211
|
+
test("captures the state the build rendered and hands the builder the same value", () => {
|
|
212
|
+
const conversation = makeConversation();
|
|
213
|
+
expect(conversation.renderedDelegateIndependentTasks).toBeUndefined();
|
|
214
|
+
|
|
215
|
+
conversation.buildCurrentSystemPrompt();
|
|
216
|
+
|
|
217
|
+
// An unrestricted turn can spawn, so the section renders on.
|
|
218
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(true);
|
|
219
|
+
expect(lastBuildDelegation()).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
test("a change to the gate's inputs after the build does not touch the captured state until the next build", () => {
|
|
223
|
+
const conversation = makeConversation();
|
|
224
|
+
conversation.buildCurrentSystemPrompt();
|
|
225
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(true);
|
|
226
|
+
|
|
227
|
+
// The loop is still sending the prompt built above, so the recorder must
|
|
228
|
+
// see that prompt's state, whatever the gate would derive now.
|
|
229
|
+
conversation.setSubagentAllowedTools(new Set(["remember"]));
|
|
230
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(true);
|
|
231
|
+
|
|
232
|
+
conversation.buildCurrentSystemPrompt();
|
|
233
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(false);
|
|
234
|
+
expect(lastBuildDelegation()).toBe(false);
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
test("a failed build leaves the previously captured state in place", () => {
|
|
238
|
+
const conversation = makeConversation();
|
|
239
|
+
conversation.buildCurrentSystemPrompt();
|
|
240
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(true);
|
|
241
|
+
|
|
242
|
+
// A wake's prompt sync swallows this and runs on the previous prompt,
|
|
243
|
+
// whose section is still on, even though a rebuild would now render it
|
|
244
|
+
// off.
|
|
245
|
+
conversation.setSubagentAllowedTools(new Set(["remember"]));
|
|
246
|
+
throwOnBuild = true;
|
|
247
|
+
expect(() => conversation.buildCurrentSystemPrompt()).toThrow(
|
|
248
|
+
"prompt build failed",
|
|
249
|
+
);
|
|
250
|
+
|
|
251
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(true);
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test("a verbatim system-prompt override captures unknown", () => {
|
|
255
|
+
const conversation = makeConversation("override prompt");
|
|
256
|
+
builtWith = [];
|
|
257
|
+
|
|
258
|
+
expect(conversation.buildCurrentSystemPrompt()).toBe("override prompt");
|
|
259
|
+
|
|
260
|
+
expect(conversation.renderedDelegateIndependentTasks).toBeNull();
|
|
261
|
+
// The override is returned as is; nothing is built.
|
|
262
|
+
expect(builtWith).toEqual([]);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
test("a replaying wake's recorded state wins over the derivation", () => {
|
|
266
|
+
const conversation = makeConversation();
|
|
267
|
+
// Replaying a channel-sourced conversation that recorded the section off,
|
|
268
|
+
// on a wake whose own scope could spawn.
|
|
269
|
+
conversation.delegateIndependentTasksReplay = false;
|
|
270
|
+
|
|
271
|
+
conversation.buildCurrentSystemPrompt();
|
|
272
|
+
|
|
273
|
+
expect(conversation.renderedDelegateIndependentTasks).toBe(false);
|
|
274
|
+
expect(lastBuildDelegation()).toBe(false);
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Tests for the per-conversation wire
|
|
3
|
-
* (`persistence/conversation-tool-surface.ts`): the
|
|
4
|
-
* tools array a live turn sends
|
|
5
|
-
*
|
|
2
|
+
* Tests for the per-conversation wire surface record
|
|
3
|
+
* (`persistence/conversation-tool-surface.ts`): the send-boundary recorder
|
|
4
|
+
* stores the tools array a live turn sends with the delegation-section state
|
|
5
|
+
* its prompt rendered, fork wakes read both back verbatim, and an unchanged
|
|
6
|
+
* surface is never rewritten.
|
|
6
7
|
*/
|
|
7
8
|
|
|
8
9
|
import { Database } from "bun:sqlite";
|
|
@@ -11,12 +12,18 @@ import { afterEach, beforeEach, describe, expect, test } from "bun:test";
|
|
|
11
12
|
import { drizzle } from "drizzle-orm/bun-sqlite";
|
|
12
13
|
|
|
13
14
|
import {
|
|
15
|
+
getConversationToolSurface as getConversationToolSurfaceViaPluginApi,
|
|
16
|
+
getRecordedConversationToolSurface,
|
|
17
|
+
} from "../persistence/conversation-plugin-facade.js";
|
|
18
|
+
import {
|
|
19
|
+
type ConversationToolSurface,
|
|
14
20
|
getConversationToolSurface,
|
|
15
21
|
hashConversationToolSurface,
|
|
16
22
|
recordConversationToolSurface,
|
|
17
23
|
} from "../persistence/conversation-tool-surface.js";
|
|
18
24
|
import { clearStoredDb, setStoredDb } from "../persistence/db-singleton.js";
|
|
19
25
|
import { migrateCreateConversationToolSurfaces } from "../persistence/migrations/378-create-conversation-tool-surfaces.js";
|
|
26
|
+
import { migrateConversationToolSurfacesDelegateIndependentTasks } from "../persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.js";
|
|
20
27
|
import * as schema from "../persistence/schema/index.js";
|
|
21
28
|
import type { ToolDefinition } from "../providers/types.js";
|
|
22
29
|
|
|
@@ -29,6 +36,7 @@ beforeEach(() => {
|
|
|
29
36
|
sqlite.query(`INSERT INTO conversations (id) VALUES (?)`).run("conv-1");
|
|
30
37
|
const db = drizzle(sqlite, { schema });
|
|
31
38
|
migrateCreateConversationToolSurfaces(db);
|
|
39
|
+
migrateConversationToolSurfacesDelegateIndependentTasks(db);
|
|
32
40
|
setStoredDb("main", db, () => sqlite.close());
|
|
33
41
|
});
|
|
34
42
|
|
|
@@ -45,6 +53,11 @@ const TOOLS: ToolDefinition[] = [
|
|
|
45
53
|
{ name: "bell_jingle", description: "Ring", input_schema: {} },
|
|
46
54
|
];
|
|
47
55
|
|
|
56
|
+
const SURFACE: ConversationToolSurface = {
|
|
57
|
+
tools: TOOLS,
|
|
58
|
+
delegateIndependentTasks: true,
|
|
59
|
+
};
|
|
60
|
+
|
|
48
61
|
function storedRow(): { tools_hash: string; updated_at: number } | null {
|
|
49
62
|
return sqlite
|
|
50
63
|
.query(
|
|
@@ -54,12 +67,12 @@ function storedRow(): { tools_hash: string; updated_at: number } | null {
|
|
|
54
67
|
}
|
|
55
68
|
|
|
56
69
|
describe("recordConversationToolSurface", () => {
|
|
57
|
-
test("stores the
|
|
58
|
-
const hash = recordConversationToolSurface("conv-1",
|
|
70
|
+
test("stores the surface and returns its content hash", () => {
|
|
71
|
+
const hash = recordConversationToolSurface("conv-1", SURFACE);
|
|
59
72
|
|
|
60
|
-
expect(hash).toBe(hashConversationToolSurface(
|
|
73
|
+
expect(hash).toBe(hashConversationToolSurface(SURFACE));
|
|
61
74
|
expect(storedRow()?.tools_hash).toBe(hash);
|
|
62
|
-
expect(getConversationToolSurface("conv-1")).toEqual(
|
|
75
|
+
expect(getConversationToolSurface("conv-1")).toEqual(SURFACE);
|
|
63
76
|
});
|
|
64
77
|
|
|
65
78
|
test("replays the exact array, including key order and extra fields", () => {
|
|
@@ -70,15 +83,31 @@ describe("recordConversationToolSurface", () => {
|
|
|
70
83
|
} as unknown as ToolDefinition;
|
|
71
84
|
const tools = [...TOOLS, serverTool];
|
|
72
85
|
|
|
73
|
-
recordConversationToolSurface("conv-1",
|
|
86
|
+
recordConversationToolSurface("conv-1", {
|
|
87
|
+
tools,
|
|
88
|
+
delegateIndependentTasks: false,
|
|
89
|
+
});
|
|
74
90
|
|
|
75
|
-
expect(JSON.stringify(getConversationToolSurface("conv-1"))).toBe(
|
|
91
|
+
expect(JSON.stringify(getConversationToolSurface("conv-1")?.tools)).toBe(
|
|
76
92
|
JSON.stringify(tools),
|
|
77
93
|
);
|
|
78
94
|
});
|
|
79
95
|
|
|
96
|
+
test("the delegation-section state round-trips, unknown included", () => {
|
|
97
|
+
for (const delegateIndependentTasks of [true, false, null]) {
|
|
98
|
+
recordConversationToolSurface("conv-1", {
|
|
99
|
+
tools: TOOLS,
|
|
100
|
+
delegateIndependentTasks,
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(
|
|
104
|
+
getConversationToolSurface("conv-1")?.delegateIndependentTasks,
|
|
105
|
+
).toBe(delegateIndependentTasks);
|
|
106
|
+
}
|
|
107
|
+
});
|
|
108
|
+
|
|
80
109
|
test("an unchanged surface with a known hash skips the write", () => {
|
|
81
|
-
const hash = recordConversationToolSurface("conv-1",
|
|
110
|
+
const hash = recordConversationToolSurface("conv-1", SURFACE);
|
|
82
111
|
const before = storedRow()!.updated_at;
|
|
83
112
|
sqlite
|
|
84
113
|
.query(
|
|
@@ -86,13 +115,13 @@ describe("recordConversationToolSurface", () => {
|
|
|
86
115
|
)
|
|
87
116
|
.run(before - 1000, "conv-1");
|
|
88
117
|
|
|
89
|
-
recordConversationToolSurface("conv-1",
|
|
118
|
+
recordConversationToolSurface("conv-1", SURFACE, hash);
|
|
90
119
|
|
|
91
120
|
expect(storedRow()!.updated_at).toBe(before - 1000);
|
|
92
121
|
});
|
|
93
122
|
|
|
94
123
|
test("an unchanged surface with no known hash compares against the stored row before writing", () => {
|
|
95
|
-
recordConversationToolSurface("conv-1",
|
|
124
|
+
recordConversationToolSurface("conv-1", SURFACE);
|
|
96
125
|
const marker = 42;
|
|
97
126
|
sqlite
|
|
98
127
|
.query(
|
|
@@ -102,14 +131,27 @@ describe("recordConversationToolSurface", () => {
|
|
|
102
131
|
|
|
103
132
|
// A freshly loaded conversation knows no hash yet; the stored row matches,
|
|
104
133
|
// so nothing is rewritten.
|
|
105
|
-
recordConversationToolSurface("conv-1",
|
|
134
|
+
recordConversationToolSurface("conv-1", SURFACE, undefined);
|
|
106
135
|
|
|
107
136
|
expect(storedRow()!.updated_at).toBe(marker);
|
|
108
137
|
});
|
|
109
138
|
|
|
110
|
-
test("a changed
|
|
111
|
-
const first = recordConversationToolSurface("conv-1",
|
|
112
|
-
const changed = TOOLS.slice(0, 1);
|
|
139
|
+
test("a changed array overwrites the stored surface", () => {
|
|
140
|
+
const first = recordConversationToolSurface("conv-1", SURFACE);
|
|
141
|
+
const changed = { ...SURFACE, tools: TOOLS.slice(0, 1) };
|
|
142
|
+
|
|
143
|
+
const second = recordConversationToolSurface("conv-1", changed, first);
|
|
144
|
+
|
|
145
|
+
expect(second).not.toBe(first);
|
|
146
|
+
expect(storedRow()?.tools_hash).toBe(second);
|
|
147
|
+
expect(getConversationToolSurface("conv-1")).toEqual(changed);
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test("a change to the delegation-section state alone overwrites it too", () => {
|
|
151
|
+
// The state is part of the hash: a turn whose scope flips the section
|
|
152
|
+
// (a background run's allowlist after an interactive turn) records it.
|
|
153
|
+
const first = recordConversationToolSurface("conv-1", SURFACE);
|
|
154
|
+
const changed = { ...SURFACE, delegateIndependentTasks: false };
|
|
113
155
|
|
|
114
156
|
const second = recordConversationToolSurface("conv-1", changed, first);
|
|
115
157
|
|
|
@@ -117,6 +159,26 @@ describe("recordConversationToolSurface", () => {
|
|
|
117
159
|
expect(storedRow()?.tools_hash).toBe(second);
|
|
118
160
|
expect(getConversationToolSurface("conv-1")).toEqual(changed);
|
|
119
161
|
});
|
|
162
|
+
|
|
163
|
+
test("a row recorded before the state existed reads unknown and is replaced on the next record", () => {
|
|
164
|
+
sqlite
|
|
165
|
+
.query(
|
|
166
|
+
/*sql*/ `INSERT INTO conversation_tool_surfaces (conversation_id, tools_json, tools_hash, updated_at) VALUES (?, ?, ?, ?)`,
|
|
167
|
+
)
|
|
168
|
+
.run("conv-1", JSON.stringify(TOOLS), "pre-upgrade-hash", 1);
|
|
169
|
+
|
|
170
|
+
expect(getConversationToolSurface("conv-1")).toEqual({
|
|
171
|
+
tools: TOOLS,
|
|
172
|
+
delegateIndependentTasks: null,
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// The stored hash predates the state, so the next live turn's record
|
|
176
|
+
// rewrites the row with it.
|
|
177
|
+
const hash = recordConversationToolSurface("conv-1", SURFACE, undefined);
|
|
178
|
+
|
|
179
|
+
expect(storedRow()?.tools_hash).toBe(hash);
|
|
180
|
+
expect(getConversationToolSurface("conv-1")).toEqual(SURFACE);
|
|
181
|
+
});
|
|
120
182
|
});
|
|
121
183
|
|
|
122
184
|
describe("getConversationToolSurface", () => {
|
|
@@ -135,10 +197,25 @@ describe("getConversationToolSurface", () => {
|
|
|
135
197
|
});
|
|
136
198
|
|
|
137
199
|
test("the row cascades with its conversation", () => {
|
|
138
|
-
recordConversationToolSurface("conv-1",
|
|
200
|
+
recordConversationToolSurface("conv-1", SURFACE);
|
|
139
201
|
|
|
140
202
|
sqlite.query(`DELETE FROM conversations WHERE id = ?`).run("conv-1");
|
|
141
203
|
|
|
142
204
|
expect(getConversationToolSurface("conv-1")).toBeNull();
|
|
143
205
|
});
|
|
144
206
|
});
|
|
207
|
+
|
|
208
|
+
describe("plugin facade", () => {
|
|
209
|
+
test("the array accessor is a projection of the recorded surface", async () => {
|
|
210
|
+
expect(await getConversationToolSurfaceViaPluginApi("conv-1")).toBeNull();
|
|
211
|
+
expect(await getRecordedConversationToolSurface("conv-1")).toBeNull();
|
|
212
|
+
|
|
213
|
+
recordConversationToolSurface("conv-1", SURFACE);
|
|
214
|
+
|
|
215
|
+
expect(await getRecordedConversationToolSurface("conv-1")).toEqual(SURFACE);
|
|
216
|
+
// The array shape the public `@vellumai/plugin-api` accessor returns.
|
|
217
|
+
expect(await getConversationToolSurfaceViaPluginApi("conv-1")).toEqual(
|
|
218
|
+
TOOLS,
|
|
219
|
+
);
|
|
220
|
+
});
|
|
221
|
+
});
|