@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.
Files changed (48) hide show
  1. package/docs/architecture/memory.md +6 -0
  2. package/docs/desktop-browser-cli.md +2 -0
  3. package/package.json +1 -1
  4. package/src/__tests__/agent-wake-delegation-prompt.test.ts +64 -2
  5. package/src/__tests__/conversation-rendered-delegation-state.test.ts +276 -0
  6. package/src/__tests__/db-conversation-tool-surface.test.ts +95 -18
  7. package/src/__tests__/subagent-tool-gate-mode.test.ts +63 -6
  8. package/src/browser/virtual-desktop-target.ts +1 -2
  9. package/src/config/schemas/__tests__/memory-v3.test.ts +12 -0
  10. package/src/config/schemas/memory-v3.ts +12 -0
  11. package/src/daemon/conversation-tool-setup.ts +20 -8
  12. package/src/daemon/conversation.ts +56 -18
  13. package/src/daemon/wake-conversation-ops.ts +12 -0
  14. package/src/desktop/desktop-automation-lease.ts +1 -2
  15. package/src/desktop/desktop-help.ts +1 -2
  16. package/src/desktop/desktop-stream-bridge.test.ts +65 -10
  17. package/src/desktop/desktop-stream-bridge.ts +3 -3
  18. package/src/desktop/virtual-desktop-feature.ts +1 -1
  19. package/src/persistence/conversation-plugin-facade.ts +21 -4
  20. package/src/persistence/conversation-tool-surface.ts +72 -23
  21. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.test.ts +90 -0
  22. package/src/persistence/migrations/380-conversation-tool-surfaces-delegate-independent-tasks.ts +34 -0
  23. package/src/persistence/schema/conversation-tool-surfaces.ts +14 -4
  24. package/src/persistence/steps.ts +9 -0
  25. package/src/plugin-api/index.ts +2 -0
  26. package/src/plugins/defaults/memory/AGENTS.md +25 -3
  27. package/src/plugins/defaults/memory/__tests__/conversation-memory-purge.test.ts +1 -0
  28. package/src/plugins/defaults/memory/__tests__/db-memory-attach.test.ts +2 -0
  29. package/src/plugins/defaults/memory/__tests__/memory-retrospective-job.test.ts +38 -10
  30. package/src/plugins/defaults/memory/__tests__/relocated-memory-test-rows.ts +10 -0
  31. package/src/plugins/defaults/memory/conversation-memory-purge.ts +4 -0
  32. package/src/plugins/defaults/memory/memory-retrospective-job.ts +23 -8
  33. package/src/plugins/defaults/memory/v3/__tests__/plugin-schema.test.ts +12 -0
  34. package/src/plugins/defaults/memory/v3/__tests__/pool-log-store.test.ts +183 -4
  35. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +82 -2
  36. package/src/plugins/defaults/memory/v3/orchestrate.ts +30 -7
  37. package/src/plugins/defaults/memory/v3/plugin-schema.ts +54 -4
  38. package/src/plugins/defaults/memory/v3/pool-log-store.ts +253 -1
  39. package/src/plugins/defaults/memory/v3/pool-select.test.ts +35 -2
  40. package/src/plugins/defaults/memory/v3/pool-select.ts +21 -12
  41. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +31 -13
  42. package/src/prompts/__tests__/parallel-tasks-section.test.ts +25 -0
  43. package/src/prompts/delegation-gate.ts +57 -0
  44. package/src/prompts/system-prompt.ts +14 -32
  45. package/src/runtime/__tests__/agent-wake.test.ts +11 -3
  46. package/src/runtime/agent-wake.ts +12 -1
  47. package/src/runtime/http-server.ts +2 -2
  48. 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vellumai/assistant",
3
- "version": "0.12.2-dev.202609170320.2f82e57",
3
+ "version": "0.12.2-dev.202609170629.61d51c8",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "exports": {
@@ -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
- canSpawnSubagentsForTurn(target as unknown as Conversation)
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 tool-surface record
3
- * (`persistence/conversation-tool-surface.ts`): the resolver records the
4
- * tools array a live turn sends, fork wakes read it back verbatim, and an
5
- * unchanged surface is never rewritten.
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 array and returns its content hash", () => {
58
- const hash = recordConversationToolSurface("conv-1", TOOLS);
70
+ test("stores the surface and returns its content hash", () => {
71
+ const hash = recordConversationToolSurface("conv-1", SURFACE);
59
72
 
60
- expect(hash).toBe(hashConversationToolSurface(TOOLS));
73
+ expect(hash).toBe(hashConversationToolSurface(SURFACE));
61
74
  expect(storedRow()?.tools_hash).toBe(hash);
62
- expect(getConversationToolSurface("conv-1")).toEqual(TOOLS);
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", tools);
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", TOOLS);
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", TOOLS, hash);
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", TOOLS);
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", TOOLS, undefined);
134
+ recordConversationToolSurface("conv-1", SURFACE, undefined);
106
135
 
107
136
  expect(storedRow()!.updated_at).toBe(marker);
108
137
  });
109
138
 
110
- test("a changed surface overwrites the stored one", () => {
111
- const first = recordConversationToolSurface("conv-1", TOOLS);
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", TOOLS);
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
+ });