summaryrefslogtreecommitdiffhomepage
path: root/packages/api
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
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')
-rw-r--r--packages/api/src/agent-manager.ts401
-rw-r--r--packages/api/tests/agent-manager.test.ts111
-rw-r--r--packages/api/tests/routes.test.ts114
3 files changed, 528 insertions, 98 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 889142d..e1d9bad 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -1,46 +1,94 @@
import {
Agent,
type AgentEvent,
- type AgentStatus,
- type DispatchConfig,
type AgentSkillMapping,
- type SkillDefinition,
+ type AgentStatus,
+ appendMessage,
+ type ClaudeAccount,
+ configToRuleset,
+ createConfigWatcher,
createListFilesTool,
createReadFileTool,
+ createRetrieveTool,
createRunShellTool,
+ createSkillsWatcher,
+ createSummonTool,
+ createTaskListTool,
createWriteFileTool,
+ type DispatchConfig,
+ getClaudeAccountsFromDB,
+ getSetting,
loadConfig,
- configToRuleset,
- validateConfig,
- createConfigWatcher,
loadSkills,
- createSkillsWatcher,
ModelRegistry,
- TaskList,
- createTaskListTool,
- type ClaudeAccount,
- appendMessage,
- getClaudeAccountsFromDB,
refreshAccountCredentials,
refreshAccountCredentialsAsync,
resolveApiKey,
- getSetting,
+ type SkillDefinition,
+ TaskList,
+ validateConfig,
} from "@dispatch/core";
import type { PermissionManager } from "./permission-manager.js";
import { setConfigGetter } from "./routes/config.js";
+import { setAccountsGetter, setModelsGetter } from "./routes/models.js";
import { setSkillsGetter } from "./routes/skills.js";
-import { setModelsGetter, setAccountsGetter } from "./routes/models.js";
import { setTabsAgentManager } from "./routes/tabs.js";
const TOOL_DESCRIPTIONS: Record<string, string> = {
read_file: "Read the contents of a file",
list_files: "List files and directories",
write_file: "Write content to a file (creates parent directories if needed)",
- run_shell: "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
- task_list: "Manage a task list for tracking work items.",
+ run_shell:
+ "Execute shell commands in the working directory (bash). Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. Do NOT run destructive or irreversible commands unless the user explicitly requests them.",
+ todo: "Manage a todo list for planning and tracking work. Actions: add, update, list, get, remove. Statuses: pending, in_progress, done.",
+ summon:
+ "Spawn a child agent to work on a task independently. Returns an agent_id immediately (non-blocking). Use retrieve to collect the result later.",
+ retrieve:
+ "Wait for a child agent to finish and get its result (blocking). Pass the agent_id from summon.",
};
-const DEFAULT_SYSTEM_PROMPT = "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise.";
+const DEFAULT_SYSTEM_PROMPT =
+ "You are Dispatch, an agent designed to help with any task that the user asks for. Be helpful and concise.";
+
+const TODO_GUIDANCE = `
+## Todo List
+
+The user can see your todo list in real-time. Use it to communicate your plan and progress.
+
+### When to use
+- Tasks that require 3 or more steps
+- When the user provides multiple things to do
+- Complex work that benefits from planning before starting
+- After receiving new instructions, capture them as todos immediately
+
+### When NOT to use
+- Single, straightforward tasks that need no tracking
+- Purely conversational or informational responses
+- Anything completable in under 3 trivial steps
+
+### State management
+- Only ONE item should be "in_progress" at a time. Finish current work before starting the next item.
+- Mark items "done" IMMEDIATELY after completing them. Do not batch completions.
+- When starting work on an item, mark it "in_progress" first.
+- Add new items as you discover sub-tasks during execution.
+
+### Examples
+
+User: "Run the build and fix any type errors"
+Good approach:
+1. Add todo: "Run the build" -> mark in_progress -> run build -> mark done
+2. If 5 errors found, add 5 todos for each error
+3. Work through each one sequentially, marking in_progress then done
+
+User: "What does the git status command do?"
+No todo needed — this is a simple informational question.
+
+User: "Rename the function getUser to fetchUser across the project"
+Good approach:
+1. Add todo: "Search for all occurrences of getUser"
+2. After searching, add a todo per file that needs changes
+3. Work through each file sequentially
+`.trim();
function buildSystemPrompt(toolNames: string[], basePrompt?: string): string {
const base = basePrompt || DEFAULT_SYSTEM_PROMPT;
@@ -50,7 +98,13 @@ function buildSystemPrompt(toolNames: string[], basePrompt?: string): string {
.join("\n");
if (!toolList) return base;
- return `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`;
+
+ const hasTodo = toolNames.includes("todo");
+ let prompt = `${base}\n\nYou have access to the following tools:\n\n${toolList}\n\nWhen asked to work with files, use these tools. Always confirm what you did after completing an action.`;
+ if (hasTodo) {
+ prompt += `\n\n${TODO_GUIDANCE}`;
+ }
+ return prompt;
}
interface TabAgent {
@@ -60,6 +114,21 @@ interface TabAgent {
modelId: string | null;
taskList: TaskList;
_lastPermKey?: string;
+ /** Abort controller for cancelling a running agent. */
+ abortController?: AbortController;
+ /** For child agents: resolves when the agent finishes its task. */
+ completionResolve?: (
+ result: { status: "done"; result: string } | { status: "error"; error: string },
+ ) => void;
+ completionPromise?: Promise<
+ { status: "done"; result: string } | { status: "error"; error: string }
+ >;
+ /** Accumulated final text output from the child agent. */
+ finalOutput?: string;
+ /** Tools whitelist for child agents (set by summon). */
+ toolsOverride?: string[];
+ /** Working directory override for child agents. */
+ workingDirectoryOverride?: string;
}
export class AgentManager {
@@ -103,9 +172,7 @@ export class AgentManager {
// Wire route getters
setConfigGetter(() => this.config);
setSkillsGetter(() => this.skillsData);
- setModelsGetter(
- () => this.modelRegistry,
- );
+ setModelsGetter(() => this.modelRegistry);
setAccountsGetter(() => this.claudeAccounts);
setTabsAgentManager(() => this);
@@ -150,7 +217,9 @@ export class AgentManager {
console.log(`dispatch: discovered ${this.claudeAccounts.length} Claude account(s)`);
}
} catch (err) {
- console.warn(`dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`);
+ console.warn(
+ `dispatch: failed to discover Claude accounts: ${err instanceof Error ? err.message : String(err)}`,
+ );
}
}
@@ -199,44 +268,121 @@ export class AgentManager {
return tabAgent;
}
- private async getOrCreateAgentForTab(tabId: string, keyId?: string, modelId?: string): Promise<Agent> {
+ private async getOrCreateAgentForTab(
+ tabId: string,
+ keyId?: string,
+ modelId?: string,
+ ): Promise<Agent> {
const tabAgent = this._getOrCreateTabAgent(tabId);
// Determine effective override: use provided values, or fall back to stored per-tab values
const effectiveKeyId = keyId ?? tabAgent.keyId;
const effectiveModelId = modelId ?? tabAgent.modelId;
- // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask)
+ // Read tool permission settings from DB (default: read=allow, edit=ask, bash=ask, summon=ask)
const permRead = getSetting("perm_read") !== "ask";
const permEdit = getSetting("perm_edit") === "allow";
const permBash = getSetting("perm_bash") === "allow";
+ const permSummon = getSetting("perm_summon") === "allow";
const sysPrompt = getSetting("system_prompt") ?? "";
- const permKey = `${permRead}:${permEdit}:${permBash}:${sysPrompt}`;
+ const permKey = `${permRead}:${permEdit}:${permBash}:${permSummon}:${sysPrompt}`;
// If the override differs or permissions changed, invalidate the cached agent
if (
tabAgent.agent &&
- (effectiveKeyId !== tabAgent.keyId || effectiveModelId !== tabAgent.modelId || permKey !== tabAgent._lastPermKey)
+ (effectiveKeyId !== tabAgent.keyId ||
+ effectiveModelId !== tabAgent.modelId ||
+ permKey !== tabAgent._lastPermKey)
) {
tabAgent.agent = null;
}
if (!tabAgent.agent) {
- const workingDirectory = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+ const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+ const workingDirectory = tabAgent.workingDirectoryOverride ?? defaultWorkDir;
- // Build tools list based on permission settings
+ // Build tools list — child agents use their toolsOverride whitelist,
+ // parent agents use permission settings from DB
const toolEntries: Array<{ name: string; tool: ReturnType<typeof createReadFileTool> }> = [];
- if (permRead) {
- toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
- toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
- }
- if (permEdit) {
- toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) });
- }
- if (permBash) {
- toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) });
+
+ if (tabAgent.toolsOverride) {
+ // Child agent: use explicit tool whitelist
+ const allowed = new Set(tabAgent.toolsOverride);
+ if (allowed.has("read_file")) {
+ toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
+ // list_files is bundled with read access
+ if (allowed.has("list_files")) {
+ toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
+ }
+ }
+ if (allowed.has("list_files") && !allowed.has("read_file")) {
+ toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
+ }
+ if (allowed.has("write_file")) {
+ toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) });
+ }
+ if (allowed.has("run_shell")) {
+ toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) });
+ }
+ if (allowed.has("todo")) {
+ toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
+ }
+ if (allowed.has("summon")) {
+ const childParentAllowedTools = new Set(toolEntries.map((e) => e.name));
+ toolEntries.push({
+ name: "summon",
+ tool: createSummonTool(workingDirectory, {
+ spawn: (opts) =>
+ this.spawnChildAgent({
+ ...opts,
+ parentKeyId: tabAgent.keyId,
+ parentModelId: tabAgent.modelId,
+ parentAllowedTools: childParentAllowedTools,
+ }),
+ }),
+ });
+ }
+ if (allowed.has("retrieve")) {
+ toolEntries.push({
+ name: "retrieve",
+ tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }),
+ });
+ }
+ } else {
+ // Parent agent: use permission settings from DB
+ if (permRead) {
+ toolEntries.push({ name: "read_file", tool: createReadFileTool(workingDirectory) });
+ toolEntries.push({ name: "list_files", tool: createListFilesTool(workingDirectory) });
+ }
+ if (permEdit) {
+ toolEntries.push({ name: "write_file", tool: createWriteFileTool(workingDirectory) });
+ }
+ if (permBash) {
+ toolEntries.push({ name: "run_shell", tool: createRunShellTool(workingDirectory) });
+ }
+ toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
+ if (permSummon) {
+ // Capture parent's allowed tool names for child permission enforcement
+ const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
+ toolEntries.push({
+ name: "summon",
+ tool: createSummonTool(workingDirectory, {
+ spawn: (opts) =>
+ this.spawnChildAgent({
+ ...opts,
+ parentKeyId: tabAgent.keyId,
+ parentModelId: tabAgent.modelId,
+ parentAllowedTools,
+ }),
+ }),
+ });
+ toolEntries.push({
+ name: "retrieve",
+ tool: createRetrieveTool({ getResult: (id) => this.getChildResult(id) }),
+ });
+ }
}
- toolEntries.push({ name: "task_list", tool: createTaskListTool(tabAgent.taskList) });
+
const tools = toolEntries.map((e) => e.tool);
const toolNames = toolEntries.map((e) => e.name);
tabAgent._lastPermKey = permKey;
@@ -254,14 +400,19 @@ export class AgentManager {
if (effectiveKeyId && effectiveModelId && this.modelRegistry) {
// Direct override: look up the key by id in the registry
- const keyState = this.modelRegistry.getKeys().find((k) => k.definition.id === effectiveKeyId);
+ const keyState = this.modelRegistry
+ .getKeys()
+ .find((k) => k.definition.id === effectiveKeyId);
if (keyState) {
const key = keyState.definition;
if (key.provider === "anthropic") {
// Anthropic provider: resolve credentials from Claude accounts
const credFile = key.credentials_file;
- const account = this.claudeAccounts.find((a) => a.id === effectiveKeyId)
- ?? (credFile ? this.claudeAccounts.find((a) => a.source === credFile) : this.claudeAccounts[0]);
+ const account =
+ this.claudeAccounts.find((a) => a.id === effectiveKeyId) ??
+ (credFile
+ ? this.claudeAccounts.find((a) => a.source === credFile)
+ : this.claudeAccounts[0]);
if (account) {
const creds = refreshAccountCredentials(account);
if (creds && creds.expiresAt > Date.now() + 60_000) {
@@ -287,7 +438,9 @@ export class AgentManager {
tabAgent.modelId = effectiveModelId;
useOverride = true;
} else {
- console.warn(`dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`);
+ console.warn(
+ `dispatch: unable to refresh Claude credentials for "${account.label}" — using stale token`,
+ );
claudeCredentials = { accessToken: account.credentials.accessToken };
apiKey = account.credentials.accessToken;
baseURL = key.base_url;
@@ -312,7 +465,9 @@ export class AgentManager {
tabAgent.modelId = effectiveModelId;
useOverride = true;
} else {
- console.warn(`dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`);
+ console.warn(
+ `dispatch: env var "${key.env}" not set for key "${key.id}", falling back to env vars`,
+ );
tabAgent.keyId = effectiveKeyId;
tabAgent.modelId = effectiveModelId;
useOverride = true;
@@ -387,8 +542,11 @@ export class AgentManager {
stopTab(tabId: string): void {
const tabAgent = this.tabAgents.get(tabId);
if (tabAgent) {
+ tabAgent.abortController?.abort();
tabAgent.status = "idle";
tabAgent.agent = null;
+ // Resolve any pending completion promise so retrieve doesn't hang
+ tabAgent.completionResolve?.({ status: "error", error: "Agent was stopped." });
}
}
@@ -397,8 +555,113 @@ export class AgentManager {
this.tabAgents.delete(tabId);
}
- async processMessage(tabId: string, message: string, keyId?: string, modelId?: string, reasoningEffort?: "none" | "low" | "medium" | "high" | "max"): Promise<void> {
+ /**
+ * Spawn a child agent in a new tab. Returns the tab ID (agent_id).
+ * The child runs asynchronously — use getChildResult to await completion.
+ */
+ async spawnChildAgent(options: {
+ task: string;
+ tools: string[];
+ workingDirectory?: string;
+ parentKeyId?: string | null;
+ parentModelId?: string | null;
+ parentAllowedTools?: Set<string>;
+ }): Promise<string> {
+ const tabId = crypto.randomUUID();
+ const title = options.task.length > 50 ? `${options.task.slice(0, 47)}...` : options.task;
+
+ // Validate working directory is within the parent's workspace
+ const defaultWorkDir = process.env.DISPATCH_WORKING_DIR ?? process.cwd();
+ if (options.workingDirectory) {
+ const { resolve } = await import("node:path");
+ const resolved = resolve(options.workingDirectory);
+ const parentDir = resolve(defaultWorkDir);
+ if (!resolved.startsWith(`${parentDir}/`) && resolved !== parentDir) {
+ throw new Error(
+ `Working directory "${options.workingDirectory}" is outside the workspace "${parentDir}".`,
+ );
+ }
+ }
+
+ // Intersect requested tools with parent's allowed tools to prevent privilege escalation
+ let childTools = options.tools;
+ if (options.parentAllowedTools) {
+ childTools = options.tools.filter((t) => options.parentAllowedTools!.has(t));
+ }
+
+ // Create the tab agent entry with overrides
const tabAgent = this._getOrCreateTabAgent(tabId);
+ tabAgent.toolsOverride = childTools;
+ tabAgent.workingDirectoryOverride = options.workingDirectory;
+ tabAgent.keyId = options.parentKeyId ?? null;
+ tabAgent.modelId = options.parentModelId ?? null;
+ tabAgent.finalOutput = "";
+
+ // Set up completion tracking
+ tabAgent.completionPromise = new Promise((resolve) => {
+ tabAgent.completionResolve = resolve;
+ });
+
+ // Create tab in DB
+ try {
+ const { createTab } = await import("@dispatch/core");
+ createTab(tabId, title);
+ } catch {
+ // Continue even if DB fails
+ }
+
+ // Notify the frontend about the new tab
+ this.emit({ type: "tab-created", id: tabId, title }, tabId);
+
+ // Start the child agent in the background
+ this.processMessage(
+ tabId,
+ options.task,
+ options.parentKeyId ?? undefined,
+ options.parentModelId ?? undefined,
+ ).catch((err) => {
+ const errorMsg = err instanceof Error ? err.message : String(err);
+ tabAgent.completionResolve?.({ status: "error", error: errorMsg });
+ });
+
+ return tabId;
+ }
+
+ /**
+ * Wait for a child agent to finish and return its result.
+ * Blocks until the child completes or errors.
+ */
+ async getChildResult(
+ agentId: string,
+ ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> {
+ const tabAgent = this.tabAgents.get(agentId);
+ if (!tabAgent) {
+ return { status: "error", error: `No agent found with id '${agentId}'` };
+ }
+
+ if (!tabAgent.completionPromise) {
+ // Not a child agent or already completed
+ if (tabAgent.status === "idle") {
+ return { status: "done", result: tabAgent.finalOutput ?? "(no output)" };
+ }
+ return {
+ status: "error",
+ error: "Agent has no completion tracking. It may not have been spawned via summon.",
+ };
+ }
+
+ return tabAgent.completionPromise;
+ }
+
+ async processMessage(
+ tabId: string,
+ message: string,
+ keyId?: string,
+ modelId?: string,
+ reasoningEffort?: "none" | "low" | "medium" | "high" | "max",
+ ): Promise<void> {
+ const tabAgent = this._getOrCreateTabAgent(tabId);
+ tabAgent.abortController = new AbortController();
tabAgent.status = "running";
this.messageCount += 1;
@@ -406,13 +669,31 @@ export class AgentManager {
const agent = await this.getOrCreateAgentForTab(tabId, keyId, modelId);
// Persist user message to DB
- appendMessage(tabId, crypto.randomUUID(), "user", JSON.stringify([{ type: "text", text: message }]));
-
+ appendMessage(
+ tabId,
+ crypto.randomUUID(),
+ "user",
+ JSON.stringify([{ type: "text", text: message }]),
+ );
+
+ let allOutput = "";
let assistantText = "";
let assistantThinking = "";
- const assistantToolCalls: Array<{ id: string; name: string; arguments: Record<string, unknown>; result?: string; isError?: boolean }> = [];
+ const assistantToolCalls: Array<{
+ id: string;
+ name: string;
+ arguments: Record<string, unknown>;
+ result?: string;
+ isError?: boolean;
+ }> = [];
+
+ for await (const event of agent.run(
+ message,
+ reasoningEffort ? { reasoningEffort } : undefined,
+ )) {
+ // Stop processing if the tab was aborted (closed/stopped)
+ if (tabAgent.abortController?.signal.aborted) break;
- for await (const event of agent.run(message, reasoningEffort ? { reasoningEffort } : undefined)) {
if (event.type === "status") {
tabAgent.status = event.status;
}
@@ -421,13 +702,21 @@ export class AgentManager {
// Accumulate content for DB persistence
if (event.type === "text-delta") {
assistantText += event.delta;
+ allOutput += event.delta;
} else if (event.type === "reasoning-delta") {
assistantThinking += event.delta;
} else if (event.type === "tool-call") {
- assistantToolCalls.push({ id: event.toolCall.id, name: event.toolCall.name, arguments: event.toolCall.arguments });
+ assistantToolCalls.push({
+ id: event.toolCall.id,
+ name: event.toolCall.name,
+ arguments: event.toolCall.arguments,
+ });
} else if (event.type === "tool-result") {
const tc = assistantToolCalls.find((t) => t.id === event.toolResult.toolCallId);
- if (tc) { tc.result = event.toolResult.result; tc.isError = event.toolResult.isError; }
+ if (tc) {
+ tc.result = event.toolResult.result;
+ tc.isError = event.toolResult.isError;
+ }
} else if (event.type === "done") {
// Persist assistant message to DB
const contentSegments: Array<Record<string, unknown>> = [];
@@ -436,7 +725,13 @@ export class AgentManager {
contentSegments.push({ type: "tool-call", ...tc });
}
if (contentSegments.length > 0) {
- appendMessage(tabId, crypto.randomUUID(), "assistant", JSON.stringify(contentSegments), assistantThinking || undefined);
+ appendMessage(
+ tabId,
+ crypto.randomUUID(),
+ "assistant",
+ JSON.stringify(contentSegments),
+ assistantThinking || undefined,
+ );
}
// Reset for next turn
assistantText = "";
@@ -444,11 +739,15 @@ export class AgentManager {
assistantToolCalls.length = 0;
}
}
+ // Resolve completion promise for child agents
+ tabAgent.finalOutput = allOutput;
+ tabAgent.completionResolve?.({ status: "done", result: allOutput || "(no output)" });
} catch (err) {
const errorMsg = err instanceof Error ? err.message : String(err);
tabAgent.status = "error";
this.emit({ type: "error", error: errorMsg }, tabId);
this.emit({ type: "status", status: "error" }, tabId);
+ tabAgent.completionResolve?.({ status: "error", error: errorMsg });
}
}
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);
});