@vellumai/assistant 0.11.4-staging.2 → 0.11.4-staging.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (189) hide show
  1. package/AGENTS.md +8 -2
  2. package/ARCHITECTURE.md +2 -0
  3. package/docs/architecture/memory.md +15 -0
  4. package/docs/browser-use-architecture-phase2.md +128 -56
  5. package/docs/flux-turn-detection-spike.md +11 -6
  6. package/knip.json +3 -0
  7. package/openapi.yaml +136 -76
  8. package/package.json +1 -1
  9. package/scripts/write-plugin-api-shim.ts +10 -0
  10. package/src/__tests__/app-control-flow.test.ts +1 -0
  11. package/src/__tests__/approval-routes-http.test.ts +2 -2
  12. package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
  13. package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
  14. package/src/__tests__/compaction-events.test.ts +8 -10
  15. package/src/__tests__/conversation-agent-loop.test.ts +4 -1
  16. package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
  17. package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
  18. package/src/__tests__/conversation-queue.test.ts +39 -62
  19. package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
  20. package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
  21. package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
  22. package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
  23. package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
  24. package/src/__tests__/conversation-slash-queue.test.ts +3 -0
  25. package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
  26. package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
  27. package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
  28. package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
  29. package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
  30. package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
  31. package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
  32. package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
  33. package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
  34. package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
  35. package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
  36. package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
  37. package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
  38. package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
  39. package/src/__tests__/cu-unified-flow.test.ts +1 -0
  40. package/src/__tests__/document-sync-tags.test.ts +0 -75
  41. package/src/__tests__/gateway-only-guard.test.ts +2 -5
  42. package/src/__tests__/http-user-message-parity.test.ts +1 -1
  43. package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
  44. package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
  45. package/src/__tests__/media-generate-image.test.ts +131 -21
  46. package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
  47. package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
  48. package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
  49. package/src/__tests__/proxy-approval-callback.test.ts +1 -0
  50. package/src/__tests__/qdrant-manager.test.ts +14 -1
  51. package/src/__tests__/run-due-schedules.test.ts +21 -0
  52. package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
  53. package/src/__tests__/schedule-routes.test.ts +23 -0
  54. package/src/__tests__/schedule-store.test.ts +17 -0
  55. package/src/__tests__/secret-ingress-http.test.ts +1 -1
  56. package/src/__tests__/send-endpoint-busy.test.ts +3 -3
  57. package/src/__tests__/starter-task-flow.test.ts +5 -4
  58. package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
  59. package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
  60. package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
  61. package/src/__tests__/subagent-tools.test.ts +81 -101
  62. package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
  63. package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
  64. package/src/__tests__/ui-visual-surface.test.ts +1 -1
  65. package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
  66. package/src/__tests__/ui-work-result-surface.test.ts +1 -1
  67. package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
  68. package/src/__tests__/voice-session-bridge.test.ts +85 -29
  69. package/src/acp/session-manager.ts +8 -1
  70. package/src/api/surfaces.ts +5 -0
  71. package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
  72. package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
  73. package/src/calls/voice-session-bridge.ts +44 -25
  74. package/src/calls/voice-triage-escalate.ts +1 -0
  75. package/src/cli/bundled-modules.ts +29 -0
  76. package/src/cli/commands/db/repair.ts +4 -8
  77. package/src/cli/commands/domain.ts +6 -3
  78. package/src/cli/commands/email.ts +6 -3
  79. package/src/cli/commands/keys.ts +8 -3
  80. package/src/cli/commands/plugins.ts +85 -36
  81. package/src/cli/commands/schedules.ts +35 -1
  82. package/src/cli/lib/bundled-marketplace.json +1 -1
  83. package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
  84. package/src/config/assistant-feature-flags.ts +36 -15
  85. package/src/config/balanced-model-experiment.ts +35 -0
  86. package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
  87. package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
  88. package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
  89. package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
  90. package/src/config/bundled-skills/subagent/SKILL.md +17 -12
  91. package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
  92. package/src/config/call-site-defaults.ts +7 -0
  93. package/src/config/default-profile-catalog.ts +96 -4
  94. package/src/config/feature-flag-registry.json +11 -11
  95. package/src/config/skills.ts +9 -2
  96. package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
  97. package/src/daemon/conversation-agent-loop.ts +14 -13
  98. package/src/daemon/conversation-notifiers.ts +11 -9
  99. package/src/daemon/conversation-process.ts +0 -27
  100. package/src/daemon/conversation-store.ts +4 -4
  101. package/src/daemon/conversation-surfaces.ts +27 -13
  102. package/src/daemon/conversation-tool-setup.ts +2 -4
  103. package/src/daemon/conversation.ts +81 -44
  104. package/src/daemon/doordash-steps.ts +2 -2
  105. package/src/daemon/lifecycle.ts +14 -1
  106. package/src/daemon/process-message.ts +0 -13
  107. package/src/daemon/windows-compiled-entry.ts +4 -0
  108. package/src/documents/document-store.ts +5 -235
  109. package/src/hooks/types.ts +5 -0
  110. package/src/ipc/gateway-flag-listener.ts +17 -3
  111. package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
  112. package/src/live-voice/live-voice-manager.ts +16 -3
  113. package/src/live-voice/live-voice-session.ts +63 -9
  114. package/src/live-voice/windows-compiled-live-voice.ts +4 -0
  115. package/src/monitoring/control.ts +1 -0
  116. package/src/monitoring/db-integrity-sample.ts +4 -5
  117. package/src/permissions/prompter.ts +1 -5
  118. package/src/persistence/conversation-queries.ts +66 -16
  119. package/src/persistence/embeddings/qdrant-manager.ts +84 -49
  120. package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
  121. package/src/persistence/schema/documents.ts +4 -4
  122. package/src/plugin-api/constants.ts +8 -0
  123. package/src/plugin-api/index.ts +5 -1
  124. package/src/plugin-api/webhook-url.ts +13 -11
  125. package/src/plugins/defaults/main.ts +6 -7
  126. package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
  127. package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
  128. package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
  129. package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
  130. package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
  131. package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
  132. package/src/plugins/defaults/memory/v3/injector.ts +8 -0
  133. package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
  134. package/src/plugins/defaults/memory/worker-control.ts +1 -0
  135. package/src/plugins/defaults/worker-entrypoints.ts +3 -0
  136. package/src/plugins/mtime-cache.ts +17 -0
  137. package/src/prompts/templates/system-sections.ts +0 -7
  138. package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
  139. package/src/providers/__tests__/retry-callsite.test.ts +20 -0
  140. package/src/providers/openai/chat-completions-provider.ts +11 -1
  141. package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
  142. package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
  143. package/src/routes/control.ts +1 -0
  144. package/src/routes/route-host-client.ts +1 -0
  145. package/src/runtime/AGENTS.md +16 -17
  146. package/src/runtime/agent-wake.ts +15 -12
  147. package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
  148. package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
  149. package/src/runtime/routes/conversation-list-routes.ts +54 -22
  150. package/src/runtime/routes/conversation-management-routes.ts +2 -3
  151. package/src/runtime/routes/conversation-routes.ts +11 -13
  152. package/src/runtime/routes/documents-routes.ts +3 -222
  153. package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
  154. package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
  155. package/src/runtime/routes/playground/inject-failures.ts +2 -2
  156. package/src/runtime/routes/playground/reset-circuit.ts +1 -1
  157. package/src/runtime/routes/schedule-routes.ts +94 -7
  158. package/src/runtime/routes/workspace-routes.ts +0 -9
  159. package/src/runtime/routes/workspace-utils.ts +3 -13
  160. package/src/runtime/services/conversation-serializer.ts +7 -2
  161. package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
  162. package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
  163. package/src/schedule/plugin-schedule-availability.ts +58 -0
  164. package/src/schedule/plugin-schedule-declarations.ts +23 -27
  165. package/src/schedule/plugin-schedule-reconciler.ts +12 -3
  166. package/src/schedule/schedule-store.ts +5 -1
  167. package/src/schedule/scheduler.ts +9 -4
  168. package/src/schedule/worker-control.ts +1 -0
  169. package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
  170. package/src/subagent/consult-context.ts +11 -11
  171. package/src/subagent/consult-prompt.ts +26 -35
  172. package/src/subagent/manager.ts +20 -37
  173. package/src/subagent/notify.ts +7 -1
  174. package/src/subagent/types.ts +8 -6
  175. package/src/tools/acp/spawn.ts +6 -4
  176. package/src/tools/skills/scaffold-managed.ts +25 -7
  177. package/src/tools/subagent/spawn.ts +28 -88
  178. package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
  179. package/src/util/__tests__/worker-process-command.test.ts +37 -0
  180. package/src/util/logger.ts +16 -0
  181. package/src/util/worker-process.ts +37 -4
  182. package/src/windows-compiled-cli.ts +32 -0
  183. package/src/windows-compiled-entry.ts +4 -0
  184. package/src/windows-compiled-logger.ts +6 -0
  185. package/src/windows-compiled-worker-entry.ts +29 -0
  186. package/src/__tests__/document-workspace-file.test.ts +0 -467
  187. package/src/daemon/interactive-turn-sender.ts +0 -59
  188. package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
  189. package/src/subagent/consult-transcript.ts +0 -90
@@ -1,467 +0,0 @@
1
- /**
2
- * Tests for file-backed documents: the find-or-create route that gives a
3
- * workspace markdown file a document identity, and the write-through that
4
- * keeps the file in step with every document content write.
5
- */
6
- import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
7
- import { join } from "node:path";
8
- import { beforeEach, describe, expect, test } from "bun:test";
9
-
10
- import {
11
- createFileBackedDocument,
12
- getDocumentById,
13
- getDocumentByWorkspacePath,
14
- rebindDocumentsToRenamedPath,
15
- } from "../documents/document-store.js";
16
- import { getDb, getSqlite } from "../persistence/db-connection.js";
17
- import { migrateAddDocumentWorkspacePath } from "../persistence/migrations/360-add-document-workspace-path.js";
18
- import { ROUTES as DOCUMENT_ROUTES } from "../runtime/routes/documents-routes.js";
19
- import {
20
- BadRequestError,
21
- NotFoundError,
22
- UnprocessableEntityError,
23
- } from "../runtime/routes/errors.js";
24
- import type { RouteDefinition } from "../runtime/routes/types.js";
25
- import { ROUTES as WORKSPACE_ROUTES } from "../runtime/routes/workspace-routes.js";
26
- import {
27
- executeDocumentReplaceText,
28
- executeDocumentUpdate,
29
- } from "../tools/document/document-tool.js";
30
- import type { ToolContext } from "../tools/types.js";
31
- import { resetDbForTesting } from "./db-test-helpers.js";
32
-
33
- const workspaceDir = process.env.VELLUM_WORKSPACE_DIR!;
34
- const notesDir = join(workspaceDir, "notes");
35
-
36
- const CONVERSATION_ID = "conv-file-docs";
37
-
38
- interface DocumentPayload {
39
- success: boolean;
40
- surfaceId: string;
41
- title: string;
42
- content: string;
43
- wordCount: number;
44
- workspacePath: string | null;
45
- }
46
-
47
- function getRoute(operationId: string): RouteDefinition {
48
- const route = DOCUMENT_ROUTES.find((r) => r.operationId === operationId);
49
- if (!route) {
50
- throw new Error(`No document route found for operationId: ${operationId}`);
51
- }
52
- return route;
53
- }
54
-
55
- /**
56
- * Open a workspace file as a document. Returns the route payload so callers
57
- * can assert on the same shape `GET documents/{id}` serves.
58
- */
59
- async function openWorkspaceFile(
60
- path: string,
61
- conversationId = CONVERSATION_ID,
62
- ): Promise<DocumentPayload> {
63
- const route = getRoute("documentForWorkspaceFile");
64
- const result = await route.handler({ body: { path, conversationId } });
65
- return result as DocumentPayload;
66
- }
67
-
68
- /** Save a document through the client's document save route. */
69
- async function saveViaRoute(
70
- surfaceId: string,
71
- title: string,
72
- content: string,
73
- ): Promise<void> {
74
- const route = getRoute("saveDocument");
75
- await route.handler({
76
- body: {
77
- surfaceId,
78
- conversationId: CONVERSATION_ID,
79
- title,
80
- content,
81
- wordCount: content.split(/\s+/).filter((w) => w.length > 0).length,
82
- },
83
- });
84
- }
85
-
86
- function makeToolContext(): ToolContext {
87
- return {
88
- workingDir: workspaceDir,
89
- conversationId: CONVERSATION_ID,
90
- trustClass: "guardian",
91
- executionChannel: "web",
92
- };
93
- }
94
-
95
- /**
96
- * Rebuild the document tables at the pre-360 shape, then apply the migration —
97
- * the same order a real upgrade takes, so these tests exercise the migrated
98
- * schema rather than a hand-written copy of it.
99
- */
100
- function bootstrapDocumentTables(): void {
101
- resetDbForTesting();
102
- const raw = getSqlite();
103
- raw.exec(/*sql*/ `
104
- DROP TABLE IF EXISTS document_conversations;
105
- DROP TABLE IF EXISTS documents;
106
- DROP TABLE IF EXISTS conversations;
107
-
108
- CREATE TABLE conversations (
109
- id TEXT PRIMARY KEY,
110
- created_at INTEGER NOT NULL DEFAULT (strftime('%s','now') * 1000)
111
- );
112
-
113
- CREATE TABLE documents (
114
- surface_id TEXT PRIMARY KEY,
115
- conversation_id TEXT NOT NULL REFERENCES conversations(id) ON DELETE CASCADE,
116
- title TEXT NOT NULL,
117
- content TEXT NOT NULL,
118
- word_count INTEGER NOT NULL DEFAULT 0,
119
- created_at INTEGER NOT NULL,
120
- updated_at INTEGER NOT NULL
121
- );
122
-
123
- CREATE TABLE document_conversations (
124
- surface_id TEXT NOT NULL,
125
- conversation_id TEXT NOT NULL,
126
- created_at INTEGER NOT NULL,
127
- PRIMARY KEY (surface_id, conversation_id),
128
- FOREIGN KEY (surface_id) REFERENCES documents(surface_id) ON DELETE CASCADE
129
- );
130
- `);
131
- migrateAddDocumentWorkspacePath(getDb());
132
- raw
133
- .query(`INSERT OR IGNORE INTO conversations (id, created_at) VALUES (?, ?)`)
134
- .run(CONVERSATION_ID, Date.now());
135
- raw
136
- .query(`INSERT OR IGNORE INTO conversations (id, created_at) VALUES (?, ?)`)
137
- .run("conv-other", Date.now());
138
- }
139
-
140
- beforeEach(() => {
141
- bootstrapDocumentTables();
142
- rmSync(notesDir, { recursive: true, force: true });
143
- mkdirSync(notesDir, { recursive: true });
144
- });
145
-
146
- describe("POST documents/for-workspace-file", () => {
147
- test("creates a document seeded from the file, then returns the same one", async () => {
148
- writeFileSync(join(notesDir, "plan.md"), "# Plan\n\nFirst draft.");
149
-
150
- const first = await openWorkspaceFile("notes/plan.md");
151
- expect(first.success).toBe(true);
152
- expect(first.title).toBe("plan.md");
153
- expect(first.content).toBe("# Plan\n\nFirst draft.");
154
- expect(first.workspacePath).toBe("notes/plan.md");
155
-
156
- const second = await openWorkspaceFile("notes/plan.md");
157
- expect(second.surfaceId).toBe(first.surfaceId);
158
-
159
- // Non-canonical spellings of the same path resolve to the same document.
160
- const third = await openWorkspaceFile("./notes/../notes/plan.md");
161
- expect(third.surfaceId).toBe(first.surfaceId);
162
- });
163
-
164
- test("refreshes stored content from disk when the file changed underneath", async () => {
165
- const filePath = join(notesDir, "plan.md");
166
- writeFileSync(filePath, "original body");
167
-
168
- const first = await openWorkspaceFile("notes/plan.md");
169
- expect(first.content).toBe("original body");
170
-
171
- writeFileSync(filePath, "edited outside the editor");
172
-
173
- const reopened = await openWorkspaceFile("notes/plan.md");
174
- expect(reopened.surfaceId).toBe(first.surfaceId);
175
- expect(reopened.content).toBe("edited outside the editor");
176
- expect(reopened.wordCount).toBe(4);
177
- expect(getDocumentById(first.surfaceId)?.content).toBe(
178
- "edited outside the editor",
179
- );
180
- });
181
-
182
- test("associates the document with every conversation that opens it", async () => {
183
- writeFileSync(join(notesDir, "plan.md"), "shared");
184
-
185
- const first = await openWorkspaceFile("notes/plan.md");
186
- await openWorkspaceFile("notes/plan.md", "conv-other");
187
-
188
- const rows = getSqlite()
189
- .query(
190
- `SELECT conversation_id FROM document_conversations WHERE surface_id = ? ORDER BY conversation_id`,
191
- )
192
- .all(first.surfaceId) as Array<{ conversation_id: string }>;
193
- expect(rows.map((r) => r.conversation_id)).toEqual([
194
- CONVERSATION_ID,
195
- "conv-other",
196
- ]);
197
- });
198
-
199
- test("rejects paths that escape the workspace", async () => {
200
- await expect(openWorkspaceFile("../escape.md")).rejects.toBeInstanceOf(
201
- BadRequestError,
202
- );
203
- await expect(openWorkspaceFile("/etc/passwd.md")).rejects.toBeInstanceOf(
204
- BadRequestError,
205
- );
206
- await expect(
207
- openWorkspaceFile("notes/../../escape.md"),
208
- ).rejects.toBeInstanceOf(BadRequestError);
209
- });
210
-
211
- test("rejects non-markdown files", async () => {
212
- writeFileSync(join(notesDir, "data.txt"), "plain text");
213
-
214
- await expect(openWorkspaceFile("notes/data.txt")).rejects.toBeInstanceOf(
215
- UnprocessableEntityError,
216
- );
217
- });
218
-
219
- test("requires path and conversationId", () => {
220
- const route = getRoute("documentForWorkspaceFile");
221
- expect(() => route.handler({ body: {} })).toThrow(BadRequestError);
222
- expect(() => route.handler({ body: { path: "notes/plan.md" } })).toThrow(
223
- BadRequestError,
224
- );
225
- });
226
-
227
- test("404s for a file that was never there", async () => {
228
- await expect(openWorkspaceFile("notes/missing.md")).rejects.toBeInstanceOf(
229
- NotFoundError,
230
- );
231
- });
232
-
233
- test("404s distinctly for a bound document whose file was deleted", async () => {
234
- const filePath = join(notesDir, "plan.md");
235
- writeFileSync(filePath, "will be deleted");
236
- const created = await openWorkspaceFile("notes/plan.md");
237
- rmSync(filePath);
238
-
239
- await expect(openWorkspaceFile("notes/plan.md")).rejects.toThrow(
240
- /file backing this document no longer exists/,
241
- );
242
-
243
- // The document row survives; the file is never resurrected from it.
244
- expect(getDocumentById(created.surfaceId)?.content).toBe("will be deleted");
245
- expect(() => readFileSync(filePath, "utf-8")).toThrow();
246
- });
247
- });
248
-
249
- describe("write-through to the backing file", () => {
250
- test("the client's document save rewrites the file", async () => {
251
- const filePath = join(notesDir, "plan.md");
252
- writeFileSync(filePath, "original body");
253
- const doc = await openWorkspaceFile("notes/plan.md");
254
-
255
- await saveViaRoute(doc.surfaceId, "plan.md", "saved from the editor");
256
-
257
- expect(readFileSync(filePath, "utf-8")).toBe("saved from the editor");
258
- expect(getDocumentById(doc.surfaceId)?.content).toBe(
259
- "saved from the editor",
260
- );
261
- });
262
-
263
- test("a document_update tool edit rewrites the file", async () => {
264
- const filePath = join(notesDir, "plan.md");
265
- writeFileSync(filePath, "original body");
266
- const doc = await openWorkspaceFile("notes/plan.md");
267
-
268
- const result = executeDocumentUpdate(
269
- {
270
- surface_id: doc.surfaceId,
271
- content: "appended by the assistant",
272
- mode: "append",
273
- },
274
- { ...makeToolContext(), sendToClient: () => {} },
275
- );
276
- expect(result.isError).toBe(false);
277
-
278
- const expected = "original body\n\nappended by the assistant";
279
- expect(readFileSync(filePath, "utf-8")).toBe(expected);
280
- expect(getDocumentById(doc.surfaceId)?.content).toBe(expected);
281
- });
282
-
283
- test("a document_replace_text tool edit rewrites the file", async () => {
284
- const filePath = join(notesDir, "plan.md");
285
- writeFileSync(filePath, "hello world");
286
- const doc = await openWorkspaceFile("notes/plan.md");
287
-
288
- const result = executeDocumentReplaceText(
289
- { surface_id: doc.surfaceId, find: "world", replace: "there" },
290
- { ...makeToolContext(), sendToClient: () => {} },
291
- );
292
- expect(result.isError).toBe(false);
293
-
294
- expect(readFileSync(filePath, "utf-8")).toBe("hello there");
295
- expect(getDocumentById(doc.surfaceId)?.content).toBe("hello there");
296
- });
297
-
298
- test("a failed file write fails the update instead of diverging", async () => {
299
- const filePath = join(notesDir, "plan.md");
300
- writeFileSync(filePath, "original body");
301
- const doc = await openWorkspaceFile("notes/plan.md");
302
-
303
- // Replace the file with a directory: the write primitive refuses it, so
304
- // the row must stay on the content that still matches what is on disk.
305
- rmSync(filePath);
306
- mkdirSync(filePath);
307
-
308
- const result = executeDocumentUpdate(
309
- { surface_id: doc.surfaceId, content: "never lands", mode: "replace" },
310
- { ...makeToolContext(), sendToClient: () => {} },
311
- );
312
-
313
- expect(result.isError).toBe(true);
314
- expect(getDocumentById(doc.surfaceId)?.content).toBe("original body");
315
- });
316
-
317
- test("documents with no backing file are untouched by the write-through", async () => {
318
- const route = getRoute("saveDocument");
319
- await route.handler({
320
- body: {
321
- surfaceId: "doc-plain",
322
- conversationId: CONVERSATION_ID,
323
- title: "Plain",
324
- content: "db only",
325
- wordCount: 2,
326
- },
327
- });
328
-
329
- const stored = getDocumentById("doc-plain");
330
- expect(stored?.content).toBe("db only");
331
- expect(stored?.workspacePath).toBeNull();
332
- });
333
- });
334
-
335
- // ===========================================================================
336
- // Rename rebinding
337
- // ===========================================================================
338
-
339
- /** Bind a surface ID to a workspace-relative path with throwaway content. */
340
- function bindDocument(surfaceId: string, workspacePath: string): void {
341
- const created = createFileBackedDocument({
342
- surfaceId,
343
- conversationId: CONVERSATION_ID,
344
- title: workspacePath,
345
- content: "body",
346
- workspacePath,
347
- });
348
- expect(created.success).toBe(true);
349
- }
350
-
351
- describe("rebindDocumentsToRenamedPath", () => {
352
- test("moves the document bound to a renamed file", () => {
353
- bindDocument("doc-renamed", "notes/plan.md");
354
-
355
- rebindDocumentsToRenamedPath({
356
- oldPath: "notes/plan.md",
357
- newPath: "notes/roadmap.md",
358
- });
359
-
360
- expect(getDocumentById("doc-renamed")?.workspacePath).toBe(
361
- "notes/roadmap.md",
362
- );
363
- expect(getDocumentByWorkspacePath("notes/plan.md")).toBeNull();
364
- expect(getDocumentByWorkspacePath("notes/roadmap.md")?.surfaceId).toBe(
365
- "doc-renamed",
366
- );
367
- });
368
-
369
- test("moves documents nested under a renamed directory", () => {
370
- bindDocument("doc-top", "notes/plan.md");
371
- bindDocument("doc-nested", "notes/deep/spec.md");
372
-
373
- rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
374
-
375
- expect(getDocumentById("doc-top")?.workspacePath).toBe("archive/plan.md");
376
- expect(getDocumentById("doc-nested")?.workspacePath).toBe(
377
- "archive/deep/spec.md",
378
- );
379
- });
380
-
381
- test("leaves documents outside the renamed subtree alone", () => {
382
- bindDocument("doc-moved", "notes/plan.md");
383
- bindDocument("doc-sibling", "notes-archive/plan.md");
384
- bindDocument("doc-unrelated", "other/plan.md");
385
-
386
- rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
387
-
388
- expect(getDocumentById("doc-moved")?.workspacePath).toBe("archive/plan.md");
389
- expect(getDocumentById("doc-sibling")?.workspacePath).toBe(
390
- "notes-archive/plan.md",
391
- );
392
- expect(getDocumentById("doc-unrelated")?.workspacePath).toBe(
393
- "other/plan.md",
394
- );
395
- });
396
-
397
- test("keeps both bindings when the destination is already bound", () => {
398
- bindDocument("doc-source", "notes/plan.md");
399
- bindDocument("doc-destination", "notes/roadmap.md");
400
-
401
- expect(() =>
402
- rebindDocumentsToRenamedPath({
403
- oldPath: "notes/plan.md",
404
- newPath: "notes/roadmap.md",
405
- }),
406
- ).not.toThrow();
407
-
408
- expect(getDocumentById("doc-source")?.workspacePath).toBe("notes/plan.md");
409
- expect(getDocumentById("doc-destination")?.workspacePath).toBe(
410
- "notes/roadmap.md",
411
- );
412
- });
413
-
414
- test("one blocked row does not strand the rest of a directory rename", () => {
415
- bindDocument("doc-blocked", "notes/plan.md");
416
- bindDocument("doc-occupier", "archive/plan.md");
417
- bindDocument("doc-free", "notes/spec.md");
418
-
419
- rebindDocumentsToRenamedPath({ oldPath: "notes", newPath: "archive" });
420
-
421
- expect(getDocumentById("doc-blocked")?.workspacePath).toBe("notes/plan.md");
422
- expect(getDocumentById("doc-occupier")?.workspacePath).toBe(
423
- "archive/plan.md",
424
- );
425
- expect(getDocumentById("doc-free")?.workspacePath).toBe("archive/spec.md");
426
- });
427
- });
428
-
429
- describe("POST workspace/rename keeps documents bound", () => {
430
- const renameRoute = WORKSPACE_ROUTES.find(
431
- (r) => r.operationId === "workspace_rename",
432
- )!;
433
-
434
- test("a renamed file carries its document to the new path", async () => {
435
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
436
- const doc = await openWorkspaceFile("notes/plan.md");
437
- expect(doc.workspacePath).toBe("notes/plan.md");
438
-
439
- await renameRoute.handler({
440
- body: { oldPath: "notes/plan.md", newPath: "notes/roadmap.md" },
441
- });
442
-
443
- expect(getDocumentById(doc.surfaceId)?.workspacePath).toBe(
444
- "notes/roadmap.md",
445
- );
446
-
447
- // Reopening the file at its new name yields the same document identity,
448
- // so comments and history survive the rename.
449
- const reopened = await openWorkspaceFile("notes/roadmap.md");
450
- expect(reopened.surfaceId).toBe(doc.surfaceId);
451
- });
452
-
453
- test("a renamed directory carries the documents beneath it", async () => {
454
- writeFileSync(join(notesDir, "plan.md"), "# Plan");
455
- const doc = await openWorkspaceFile("notes/plan.md");
456
-
457
- await renameRoute.handler({
458
- body: { oldPath: "notes", newPath: "archive" },
459
- });
460
-
461
- expect(getDocumentById(doc.surfaceId)?.workspacePath).toBe(
462
- "archive/plan.md",
463
- );
464
-
465
- rmSync(join(workspaceDir, "archive"), { recursive: true, force: true });
466
- });
467
- });
@@ -1,59 +0,0 @@
1
- /**
2
- * The interactive-turn sender contract, in one place.
3
- *
4
- * `Conversation.sendToClient` is conversation-level mutable state: born a
5
- * no-op, bound to the SSE hub for the duration of an interactive turn, and
6
- * restored when that turn ends. Some subsystems read this conversation-level
7
- * sender instead of the turn's own `onEvent` sink; the `PermissionPrompter`
8
- * is the load-bearing one, so a `confirmation_request` raised by a turn that
9
- * never bound the sender is emitted into the no-op, reaches no client, and
10
- * hangs until the permission timeout auto-denies. Every path that runs an
11
- * interactive agent turn MUST therefore bind before the loop and restore
12
- * after it. The queue drain bypasses `processMessage`, so it uses this
13
- * helper directly.
14
- *
15
- * The restore is a snapshot of the sender state taken at bind time, not an
16
- * unconditional reset to a no-op: a live binding that predated the turn
17
- * (e.g. installed by the send route while a non-interactive turn was
18
- * running) survives the turn, so out-of-turn producers that read the live
19
- * sender (call transcript and completion notifiers) keep their client. In
20
- * the ordinary interactive flow the snapshot is the no-op the previous
21
- * turn's restore left behind, so restoring it is identical to the reset
22
- * this contract has always performed. The restore is also identity-guarded:
23
- * it only replaces the hub binding this contract installed, so a sender
24
- * some other subsystem rebound mid-turn to anything else is left alone.
25
- *
26
- * TRANSITIONAL: this helper is the paved road for a pattern we want less
27
- * of. Bind/restore of shared mutable sender state is why the drain, the
28
- * compaction card, and the host-browser proxy have each needed their own
29
- * restore step. The durable design is a per-turn sink threaded to the
30
- * prompter and the other conversation-level-sender readers, at which point
31
- * this module and every call site of it should be deleted. Prefer that
32
- * refactor over adding new bind/restore call sites.
33
- */
34
-
35
- import { broadcastMessage } from "../runtime/assistant-event-hub.js";
36
- import { getSubagentManager } from "../subagent/index.js";
37
- import type { Conversation } from "./conversation.js";
38
-
39
- /**
40
- * Point the conversation-level sender (and the subagent parent sender) at
41
- * the SSE hub for the duration of an interactive turn. Returns the restore
42
- * closure the turn's cleanup must invoke.
43
- */
44
- export function bindInteractiveTurnSender(
45
- conversation: Conversation,
46
- ): () => void {
47
- const previousSender = conversation.getCurrentSender();
48
- const previousHasNoClient = conversation.hasNoClient;
49
- conversation.updateClient(broadcastMessage, false);
50
- getSubagentManager().updateParentSender(
51
- conversation.conversationId,
52
- broadcastMessage,
53
- );
54
- return () => {
55
- if (conversation.getCurrentSender() === broadcastMessage) {
56
- conversation.updateClient(previousSender, previousHasNoClient);
57
- }
58
- };
59
- }