diff options
| author | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-22 00:19:14 +0900 |
| commit | fb97d4cb72d0a90dde102b7001603716ee6e4c3b (patch) | |
| tree | 55c6e8b56b3395008523ab94c16c4f526083d846 /packages/api/tests | |
| parent | 7884709e3b2adb1b65c1c086257e0300eed51cee (diff) | |
| download | dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.tar.gz dispatch-fb97d4cb72d0a90dde102b7001603716ee6e4c3b.zip | |
feat: agent summoning system, todo improvements, security fixes, double-execution bug fix
- Add summon/retrieve tools for spawning child agents in new tabs
- summon: non-blocking, returns agent_id immediately
- retrieve: blocking, waits for child to finish, returns result
- Child tools are intersected with parent permissions (no privilege escalation)
- Working directory validated to stay within workspace
- Abort controller stops orphaned agents on tab close
- Rename task_list tool to todo with comprehensive usage guidance in system prompt
- Rename PermissionLog.svelte to ToolPermissions.svelte
- Add 'Summon agents' toggle to tool permissions UI
- Redesign TaskListPanel with DaisyUI checkboxes (indeterminate for in-progress)
- Remove 'blocked' status from task system
- Add tab-created WebSocket event for child agent tab visibility
- Add HMR cleanup for WebSocket connections (close stale connections on hot reload)
- Fix ensureAssistantMessage to not throw on closed tabs
- Fix double tool execution: remove execute from AI SDK tool() in registry.ts
(agent.ts already executes tools manually via executeToolWithStreaming)
- Fix all pre-existing test failures (missing mocks, stale API signatures)
- Add debug info to copy button (tab ID, injected skills, all tab IDs)
- Add tab ID and tools to conversation copy output
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 111 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 114 |
2 files changed, 178 insertions, 47 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 1a9f1e2..f8e5e12 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -78,36 +78,97 @@ vi.mock("@dispatch/core", () => ({ return { close() {} }; }, ModelRegistry: class MockModelRegistry { - getModels() { return []; } - getKeys() { return []; } - getModelsByTag(_tag: string) { return []; } - getAllTags() { return []; } - hasAvailableKey(_provider: string) { return false; } - allKeysExhausted() { return true; } + getModels() { + return []; + } + getKeys() { + return []; + } + getModelsByTag(_tag: string) { + return []; + } + getAllTags() { + return []; + } + hasAvailableKey(_provider: string) { + return false; + } + allKeysExhausted() { + return true; + } markKeyExhausted() {} markKeyActive() {} updateConfig() {} }, ModelResolver: class MockModelResolver { - resolve(_tag: string) { return null; } - waitForKey() { return Promise.resolve(null); } + resolve(_tag: string) { + return null; + } + waitForKey() { + return Promise.resolve(null); + } }, TaskList: class MockTaskList { - getTasks() { return []; } - getTask() { return undefined; } - addTask() { return { id: "task-1", title: "", description: "", status: "pending" }; } - updateTask() { return undefined; } - removeTask() { return false; } - onChange(_cb: unknown) { return () => {}; } + getTasks() { + return []; + } + getTask() { + return undefined; + } + addTask() { + return { id: "task-1", title: "", description: "", status: "pending" }; + } + updateTask() { + return undefined; + } + removeTask() { + return false; + } + onChange(_cb: unknown) { + return () => {}; + } }, createTaskListTool(_taskList: unknown) { return { - name: "task_list", - description: "task list", + name: "todo", + description: "todo", parameters: { _type: "z.ZodObject", shape: {} }, execute: async () => "mock", }; }, + createSummonTool(_wd: string, _callbacks: unknown) { + return { + name: "summon", + description: "summon", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createRetrieveTool(_callbacks: unknown) { + return { + name: "retrieve", + description: "retrieve", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createTab() {}, + getClaudeAccountsFromDB() { + return []; + }, + refreshAccountCredentials() { + return null; + }, + refreshAccountCredentialsAsync() { + return Promise.resolve(null); + }, + resolveApiKey() { + return null; + }, + getSetting(_key: string) { + return null; + }, + appendMessage() {}, })); // Import after mock is defined (Vitest hoists vi.mock automatically) @@ -131,13 +192,13 @@ describe("AgentManager", () => { events.push(event); }); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(events.length).toBeGreaterThan(0); - expect(events[0]).toEqual({ type: "status", status: "running" }); + expect(events[0]).toMatchObject({ type: "status", status: "running" }); const lastEvent = events[events.length - 1]; - expect(lastEvent).toEqual({ type: "status", status: "idle" }); + expect(lastEvent).toMatchObject({ type: "status", status: "idle" }); const doneEvent = events.find((e) => e.type === "done"); expect(doneEvent).toBeDefined(); @@ -150,7 +211,7 @@ describe("AgentManager", () => { events.push(event); }); - await manager.processMessage("hello"); + await manager.processMessage("tab-1", "hello"); const textDeltas = events.filter((e) => e.type === "text-delta"); expect(textDeltas.length).toBeGreaterThan(0); @@ -158,15 +219,15 @@ describe("AgentManager", () => { it("messageCount increments after processMessage", async () => { const manager = new AgentManager(); - await manager.processMessage("hello"); + await manager.processMessage("tab-1", "hello"); expect(manager.getMessageCount()).toBe(1); - await manager.processMessage("world"); + await manager.processMessage("tab-1", "world"); expect(manager.getMessageCount()).toBe(2); }); it("status returns to idle after processMessage completes", async () => { const manager = new AgentManager(); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(manager.getStatus()).toBe("idle"); }); @@ -178,7 +239,7 @@ describe("AgentManager", () => { }); unsubscribe(); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(events.length).toBe(0); }); @@ -191,7 +252,7 @@ describe("AgentManager", () => { manager.onEvent(listener1); manager.onEvent(listener2); - await manager.processMessage("test"); + await manager.processMessage("tab-1", "test"); expect(listener1).toHaveBeenCalled(); expect(listener2).toHaveBeenCalled(); diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index 5ecfcb0..05f8358 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -79,36 +79,97 @@ vi.mock("@dispatch/core", () => ({ return { close() {} }; }, ModelRegistry: class MockModelRegistry { - getModels() { return []; } - getKeys() { return []; } - getModelsByTag(_tag: string) { return []; } - getAllTags() { return []; } - hasAvailableKey(_provider: string) { return false; } - allKeysExhausted() { return true; } + getModels() { + return []; + } + getKeys() { + return []; + } + getModelsByTag(_tag: string) { + return []; + } + getAllTags() { + return []; + } + hasAvailableKey(_provider: string) { + return false; + } + allKeysExhausted() { + return true; + } markKeyExhausted() {} markKeyActive() {} updateConfig() {} }, ModelResolver: class MockModelResolver { - resolve(_tag: string) { return null; } - waitForKey() { return Promise.resolve(null); } + resolve(_tag: string) { + return null; + } + waitForKey() { + return Promise.resolve(null); + } }, TaskList: class MockTaskList { - getTasks() { return []; } - getTask() { return undefined; } - addTask() { return { id: "task-1", title: "", description: "", status: "pending" }; } - updateTask() { return undefined; } - removeTask() { return false; } - onChange(_cb: unknown) { return () => {}; } + getTasks() { + return []; + } + getTask() { + return undefined; + } + addTask() { + return { id: "task-1", title: "", description: "", status: "pending" }; + } + updateTask() { + return undefined; + } + removeTask() { + return false; + } + onChange(_cb: unknown) { + return () => {}; + } }, createTaskListTool(_taskList: unknown) { return { - name: "task_list", - description: "task list", + name: "todo", + description: "todo", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createSummonTool(_wd: string, _callbacks: unknown) { + return { + name: "summon", + description: "summon", parameters: { _type: "z.ZodObject", shape: {} }, execute: async () => "mock", }; }, + createRetrieveTool(_callbacks: unknown) { + return { + name: "retrieve", + description: "retrieve", + parameters: { _type: "z.ZodObject", shape: {} }, + execute: async () => "mock", + }; + }, + createTab() {}, + getClaudeAccountsFromDB() { + return []; + }, + refreshAccountCredentials() { + return null; + }, + refreshAccountCredentialsAsync() { + return Promise.resolve(null); + }, + resolveApiKey() { + return null; + }, + getSetting(_key: string) { + return null; + }, + appendMessage() {}, })); const { app } = await import("../src/app.js"); @@ -137,7 +198,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "hello world" }), + body: JSON.stringify({ tabId: "tab-1", message: "hello world" }), }); expect(res.status).toBe(200); const body = await res.json(); @@ -148,7 +209,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "" }), + body: JSON.stringify({ tabId: "tab-1", message: "" }), }); expect(res.status).toBe(400); }); @@ -157,7 +218,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: " " }), + body: JSON.stringify({ tabId: "tab-1", message: " " }), }); expect(res.status).toBe(400); }); @@ -166,7 +227,16 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({}), + body: JSON.stringify({ tabId: "tab-1" }), + }); + expect(res.status).toBe(400); + }); + + it("returns 400 with missing tabId", async () => { + const res = await app.request("/chat", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ message: "hello" }), }); expect(res.status).toBe(400); }); @@ -176,7 +246,7 @@ describe("POST /chat", () => { await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "first message" }), + body: JSON.stringify({ tabId: "tab-2", message: "first message" }), }); // Small delay to let the async generator start and emit "running" status @@ -186,7 +256,7 @@ describe("POST /chat", () => { const res = await app.request("/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ message: "second message" }), + body: JSON.stringify({ tabId: "tab-2", message: "second message" }), }); expect(res.status).toBe(409); }); |
