@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.
- package/AGENTS.md +8 -2
- package/ARCHITECTURE.md +2 -0
- package/docs/architecture/memory.md +15 -0
- package/docs/browser-use-architecture-phase2.md +128 -56
- package/docs/flux-turn-detection-spike.md +11 -6
- package/knip.json +3 -0
- package/openapi.yaml +136 -76
- package/package.json +1 -1
- package/scripts/write-plugin-api-shim.ts +10 -0
- package/src/__tests__/app-control-flow.test.ts +1 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -2
- package/src/__tests__/assistant-feature-flag-guard.test.ts +25 -3
- package/src/__tests__/channel-setup-panel-ack.test.ts +1 -1
- package/src/__tests__/compaction-events.test.ts +8 -10
- package/src/__tests__/conversation-agent-loop.test.ts +4 -1
- package/src/__tests__/conversation-confirmation-signals.test.ts +112 -0
- package/src/__tests__/conversation-notifiers-provenance.test.ts +1 -1
- package/src/__tests__/conversation-queue.test.ts +39 -62
- package/src/__tests__/conversation-routes-disk-view.test.ts +1 -1
- package/src/__tests__/conversation-routes-enabled-plugins.test.ts +1 -1
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +9 -9
- package/src/__tests__/conversation-routes-hidden-queue.test.ts +1 -1
- package/src/__tests__/conversation-routes-slash-commands.test.ts +1 -1
- package/src/__tests__/conversation-slash-queue.test.ts +3 -0
- package/src/__tests__/conversation-surfaces-action-delivery.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-activation-emit.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-app-control.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-app-open.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-data-persist.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-history-restored-completion.test.ts +21 -14
- package/src/__tests__/conversation-surfaces-queued-emit.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-standalone-payloads.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-standalone.test.ts +1 -0
- package/src/__tests__/conversation-surfaces-state-update.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-table-action.test.ts +1 -1
- package/src/__tests__/conversation-surfaces-task-progress.test.ts +1 -1
- package/src/__tests__/conversation-tool-setup-app-refresh.test.ts +1 -1
- package/src/__tests__/conversation-tool-setup-attribution.test.ts +1 -1
- package/src/__tests__/cu-unified-flow.test.ts +1 -0
- package/src/__tests__/document-sync-tags.test.ts +0 -75
- package/src/__tests__/gateway-only-guard.test.ts +2 -5
- package/src/__tests__/http-user-message-parity.test.ts +1 -1
- package/src/__tests__/init-feature-flag-overrides.test.ts +49 -0
- package/src/__tests__/managed-skill-lifecycle.test.ts +7 -0
- package/src/__tests__/media-generate-image.test.ts +131 -21
- package/src/__tests__/memory-retrieval-hook.test.ts +94 -2
- package/src/__tests__/plugin-api-webhook-url.test.ts +10 -7
- package/src/__tests__/plugin-import-boundary-reverse-guard.test.ts +7 -3
- package/src/__tests__/proxy-approval-callback.test.ts +1 -0
- package/src/__tests__/qdrant-manager.test.ts +14 -1
- package/src/__tests__/run-due-schedules.test.ts +21 -0
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +187 -18
- package/src/__tests__/schedule-routes.test.ts +23 -0
- package/src/__tests__/schedule-store.test.ts +17 -0
- package/src/__tests__/secret-ingress-http.test.ts +1 -1
- package/src/__tests__/send-endpoint-busy.test.ts +3 -3
- package/src/__tests__/starter-task-flow.test.ts +5 -4
- package/src/__tests__/subagent-fork-prompt-role.test.ts +1 -1
- package/src/__tests__/subagent-spawn-and-await.test.ts +4 -7
- package/src/__tests__/subagent-tool-gate-mode.test.ts +1 -1
- package/src/__tests__/subagent-tools.test.ts +81 -101
- package/src/__tests__/surface-completion-in-flight-snapshot.test.ts +1 -0
- package/src/__tests__/ui-choice-copy-surfaces.test.ts +1 -1
- package/src/__tests__/ui-visual-surface.test.ts +1 -1
- package/src/__tests__/ui-voice-picker-surface.test.ts +1 -1
- package/src/__tests__/ui-work-result-surface.test.ts +1 -1
- package/src/__tests__/voice-scoped-grant-consumer.test.ts +5 -3
- package/src/__tests__/voice-session-bridge.test.ts +85 -29
- package/src/acp/session-manager.ts +8 -1
- package/src/api/surfaces.ts +5 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +21 -10
- package/src/calls/__tests__/voice-triage-escalate.test.ts +8 -0
- package/src/calls/voice-session-bridge.ts +44 -25
- package/src/calls/voice-triage-escalate.ts +1 -0
- package/src/cli/bundled-modules.ts +29 -0
- package/src/cli/commands/db/repair.ts +4 -8
- package/src/cli/commands/domain.ts +6 -3
- package/src/cli/commands/email.ts +6 -3
- package/src/cli/commands/keys.ts +8 -3
- package/src/cli/commands/plugins.ts +85 -36
- package/src/cli/commands/schedules.ts +35 -1
- package/src/cli/lib/bundled-marketplace.json +1 -1
- package/src/config/__tests__/balanced-model-experiment.test.ts +278 -0
- package/src/config/assistant-feature-flags.ts +36 -15
- package/src/config/balanced-model-experiment.ts +35 -0
- package/src/config/bundled-skills/image-studio/SKILL.md +5 -4
- package/src/config/bundled-skills/image-studio/TOOLS.json +1 -1
- package/src/config/bundled-skills/image-studio/tools/media-generate-image.ts +101 -0
- package/src/config/bundled-skills/skill-management/TOOLS.json +9 -3
- package/src/config/bundled-skills/subagent/SKILL.md +17 -12
- package/src/config/bundled-skills/subagent/TOOLS.json +4 -4
- package/src/config/call-site-defaults.ts +7 -0
- package/src/config/default-profile-catalog.ts +96 -4
- package/src/config/feature-flag-registry.json +11 -11
- package/src/config/skills.ts +9 -2
- package/src/daemon/__tests__/conversation-surfaces-launch.test.ts +1 -1
- package/src/daemon/conversation-agent-loop.ts +14 -13
- package/src/daemon/conversation-notifiers.ts +11 -9
- package/src/daemon/conversation-process.ts +0 -27
- package/src/daemon/conversation-store.ts +4 -4
- package/src/daemon/conversation-surfaces.ts +27 -13
- package/src/daemon/conversation-tool-setup.ts +2 -4
- package/src/daemon/conversation.ts +81 -44
- package/src/daemon/doordash-steps.ts +2 -2
- package/src/daemon/lifecycle.ts +14 -1
- package/src/daemon/process-message.ts +0 -13
- package/src/daemon/windows-compiled-entry.ts +4 -0
- package/src/documents/document-store.ts +5 -235
- package/src/hooks/types.ts +5 -0
- package/src/ipc/gateway-flag-listener.ts +17 -3
- package/src/live-voice/__tests__/live-voice-flux-turn-end.test.ts +118 -0
- package/src/live-voice/live-voice-manager.ts +16 -3
- package/src/live-voice/live-voice-session.ts +63 -9
- package/src/live-voice/windows-compiled-live-voice.ts +4 -0
- package/src/monitoring/control.ts +1 -0
- package/src/monitoring/db-integrity-sample.ts +4 -5
- package/src/permissions/prompter.ts +1 -5
- package/src/persistence/conversation-queries.ts +66 -16
- package/src/persistence/embeddings/qdrant-manager.ts +84 -49
- package/src/persistence/migrations/360-add-document-workspace-path.ts +5 -14
- package/src/persistence/schema/documents.ts +4 -4
- package/src/plugin-api/constants.ts +8 -0
- package/src/plugin-api/index.ts +5 -1
- package/src/plugin-api/webhook-url.ts +13 -11
- package/src/plugins/defaults/main.ts +6 -7
- package/src/plugins/defaults/memory/graph/__tests__/conversation-graph-memory-v2-routing.test.ts +77 -0
- package/src/plugins/defaults/memory/graph/conversation-graph-memory.ts +24 -6
- package/src/plugins/defaults/memory/hooks/user-prompt-submit.ts +53 -5
- package/src/plugins/defaults/memory/memory-retrospective-job.ts +4 -4
- package/src/plugins/defaults/memory/v3/__tests__/injection.test.ts +18 -0
- package/src/plugins/defaults/memory/v3/__tests__/shadow-plugin.test.ts +66 -1
- package/src/plugins/defaults/memory/v3/injector.ts +8 -0
- package/src/plugins/defaults/memory/v3/shadow-plugin.ts +23 -9
- package/src/plugins/defaults/memory/worker-control.ts +1 -0
- package/src/plugins/defaults/worker-entrypoints.ts +3 -0
- package/src/plugins/mtime-cache.ts +17 -0
- package/src/prompts/templates/system-sections.ts +0 -7
- package/src/providers/__tests__/context-overflow-error.test.ts +24 -0
- package/src/providers/__tests__/retry-callsite.test.ts +20 -0
- package/src/providers/openai/chat-completions-provider.ts +11 -1
- package/src/providers/speech-to-text/__tests__/deepgram-flux-realtime.test.ts +11 -6
- package/src/providers/speech-to-text/deepgram-flux-realtime.ts +9 -49
- package/src/routes/control.ts +1 -0
- package/src/routes/route-host-client.ts +1 -0
- package/src/runtime/AGENTS.md +16 -17
- package/src/runtime/agent-wake.ts +15 -12
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +170 -1
- package/src/runtime/routes/__tests__/schedule-routes-disarm-reason.test.ts +215 -0
- package/src/runtime/routes/conversation-list-routes.ts +54 -22
- package/src/runtime/routes/conversation-management-routes.ts +2 -3
- package/src/runtime/routes/conversation-routes.ts +11 -13
- package/src/runtime/routes/documents-routes.ts +3 -222
- package/src/runtime/routes/playground/__tests__/inject-failures.test.ts +2 -0
- package/src/runtime/routes/playground/__tests__/reset-circuit.test.ts +3 -0
- package/src/runtime/routes/playground/inject-failures.ts +2 -2
- package/src/runtime/routes/playground/reset-circuit.ts +1 -1
- package/src/runtime/routes/schedule-routes.ts +94 -7
- package/src/runtime/routes/workspace-routes.ts +0 -9
- package/src/runtime/routes/workspace-utils.ts +3 -13
- package/src/runtime/services/conversation-serializer.ts +7 -2
- package/src/schedule/__tests__/plugin-schedule-declarations.test.ts +68 -5
- package/src/schedule/__tests__/plugin-schedule-reconciler.test.ts +81 -0
- package/src/schedule/plugin-schedule-availability.ts +58 -0
- package/src/schedule/plugin-schedule-declarations.ts +23 -27
- package/src/schedule/plugin-schedule-reconciler.ts +12 -3
- package/src/schedule/schedule-store.ts +5 -1
- package/src/schedule/scheduler.ts +9 -4
- package/src/schedule/worker-control.ts +1 -0
- package/src/subagent/__tests__/consult-prompt.test.ts +26 -15
- package/src/subagent/consult-context.ts +11 -11
- package/src/subagent/consult-prompt.ts +26 -35
- package/src/subagent/manager.ts +20 -37
- package/src/subagent/notify.ts +7 -1
- package/src/subagent/types.ts +8 -6
- package/src/tools/acp/spawn.ts +6 -4
- package/src/tools/skills/scaffold-managed.ts +25 -7
- package/src/tools/subagent/spawn.ts +28 -88
- package/src/tools/ui-surface/surface-shape-docs.ts +1 -1
- package/src/util/__tests__/worker-process-command.test.ts +37 -0
- package/src/util/logger.ts +16 -0
- package/src/util/worker-process.ts +37 -4
- package/src/windows-compiled-cli.ts +32 -0
- package/src/windows-compiled-entry.ts +4 -0
- package/src/windows-compiled-logger.ts +6 -0
- package/src/windows-compiled-worker-entry.ts +29 -0
- package/src/__tests__/document-workspace-file.test.ts +0 -467
- package/src/daemon/interactive-turn-sender.ts +0 -59
- package/src/subagent/__tests__/consult-transcript.test.ts +0 -184
- 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
|
-
}
|