summaryrefslogtreecommitdiffhomepage
path: root/packages/api/tests
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-22 00:19:14 +0900
committerAdam Malczewski <[email protected]>2026-05-22 00:19:14 +0900
commitfb97d4cb72d0a90dde102b7001603716ee6e4c3b (patch)
tree55c6e8b56b3395008523ab94c16c4f526083d846 /packages/api/tests
parent7884709e3b2adb1b65c1c086257e0300eed51cee (diff)
downloaddispatch-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.ts111
-rw-r--r--packages/api/tests/routes.test.ts114
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);
});