diff options
| author | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-02 21:10:09 +0900 |
| commit | d9f53727845dface3e6d8a84ba2270b1de55482b (patch) | |
| tree | 6d42f0a0fbda15057296992e78c4b4e12046f9ed /packages/api/tests | |
| parent | 80212bfb009eaf71a4743310dee6ed08b8f7e1da (diff) | |
| parent | 9d8cf7005ba4c0bb8ade0775f54c2557aa1c5683 (diff) | |
| download | dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.tar.gz dispatch-d9f53727845dface3e6d8a84ba2270b1de55482b.zip | |
Merge branch 'dev' into feat/cs-code-search-tool
# Conflicts:
# packages/api/src/agent-manager.ts
# packages/api/tests/agent-manager.test.ts
# packages/frontend/src/lib/components/ToolPermissions.svelte
# packages/frontend/src/lib/settings.svelte.ts
Diffstat (limited to 'packages/api/tests')
| -rw-r--r-- | packages/api/tests/agent-manager.test.ts | 162 | ||||
| -rw-r--r-- | packages/api/tests/routes.test.ts | 30 |
2 files changed, 190 insertions, 2 deletions
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts index 970ac1d..dbbcc65 100644 --- a/packages/api/tests/agent-manager.test.ts +++ b/packages/api/tests/agent-manager.test.ts @@ -75,7 +75,11 @@ function makeRow( // because the production code reassigns `agent.messages = // rows.slice(...)` AFTER `new Agent()` returns — capturing a // reference at construction would yield a stale empty array. -const constructedAgents: Array<{ initialMessages: unknown[]; toolNames: string[] }> = []; +const constructedAgents: Array<{ + initialMessages: unknown[]; + toolNames: string[]; + systemPrompt: string; +}> = []; function resetConstructedAgents(): void { constructedAgents.length = 0; } @@ -159,8 +163,10 @@ vi.mock("@dispatch/core", () => ({ status = "idle"; messages: unknown[] = []; toolNames: string[] = []; - constructor(config: { tools?: Array<{ name: string }> }) { + systemPrompt = ""; + constructor(config: { tools?: Array<{ name: string }>; systemPrompt?: string }) { this.toolNames = (config?.tools ?? []).map((t) => t.name); + this.systemPrompt = config?.systemPrompt ?? ""; } async *run(message: string, options?: { reasoningEffort?: string }): AsyncGenerator<unknown> { // Snapshot the post-construction pre-populated message list @@ -170,6 +176,7 @@ vi.mock("@dispatch/core", () => ({ constructedAgents.push({ initialMessages: [...this.messages], toolNames: [...this.toolNames], + systemPrompt: this.systemPrompt, }); capturedRunOptions.push(options); if (runImpl) { @@ -221,6 +228,36 @@ vi.mock("@dispatch/core", () => ({ execute: async () => ["file1.ts"], }; }, + createLspTool(_getContext: unknown): ToolDefinition { + return { + name: "lsp", + description: "query the language server", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock lsp", + }; + }, + LspManager: class MockLspManager { + hasServerForFile() { + return false; + } + async getClients() { + return []; + } + async touchFile() {} + getDiagnostics() { + return {}; + } + async request() { + return []; + } + async shutdownAll() {} + }, + resolveServersFromConfig(_lsp: unknown) { + return []; + }, + reportDiagnostics(_file: string, _issues: unknown) { + return ""; + }, createRunShellTool(_wd: string): ToolDefinition { return { name: "run_shell", @@ -319,6 +356,22 @@ vi.mock("@dispatch/core", () => ({ execute: async () => "mock", }; }, + // Summon parent-path dependencies. The real implementations load agent + // definitions from disk; tests only need the summon/retrieve tool entries + // to appear, so these return empty projections. + loadAgents() { + return []; + }, + toAvailableSubagents() { + return []; + }, + toAvailableUserAgents() { + return []; + }, + getAgentDirPaths() { + return []; + }, + GLOBAL_AGENTS_DIR: "/tmp/global-agents", createTab() {}, getTab(id: string) { return fakeTabs.get(id) ?? null; @@ -1470,6 +1523,111 @@ describe("AgentManager", () => { }); }); + describe("summon / user_agent permission split", () => { + // Drives the real parent-path tool construction in + // getOrCreateAgentForTab by toggling perm_summon and perm_user_agent + // independently, then inspecting which tools the constructed Agent + // received. The summon tool must be registered when EITHER permission + // is granted; `retrieve` rides with the subagent permission only + // (user agents are fire-and-forget). + async function toolsForPerms(tabId: string, perms: Record<string, string>): Promise<string[]> { + for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v); + const manager = new AgentManager(); + await manager.processMessage(tabId, "go"); + return constructedAgents.at(-1)?.toolNames ?? []; + } + + it("grants summon + retrieve when only perm_summon is allowed", async () => { + const tools = await toolsForPerms("tab-summon-only", { perm_summon: "allow" }); + expect(tools).toContain("summon"); + expect(tools).toContain("retrieve"); + }); + + it("grants summon WITHOUT retrieve when only perm_user_agent is allowed", async () => { + // Regression: granting only the user-agent permission used to leave + // the agent unable to summon user agents because the whole summon + // tool was gated behind perm_summon. + const tools = await toolsForPerms("tab-user-agent-only", { perm_user_agent: "allow" }); + expect(tools).toContain("summon"); + expect(tools).not.toContain("retrieve"); + }); + + it("grants summon + retrieve when both permissions are allowed", async () => { + const tools = await toolsForPerms("tab-summon-both", { + perm_summon: "allow", + perm_user_agent: "allow", + }); + expect(tools).toContain("summon"); + expect(tools).toContain("retrieve"); + }); + + it("grants neither summon nor retrieve when both permissions are off", async () => { + const tools = await toolsForPerms("tab-summon-neither", {}); + expect(tools).not.toContain("summon"); + expect(tools).not.toContain("retrieve"); + }); + }); + + // Regression: granted tab-messaging tools must also be ADVERTISED in the + // agent's system prompt. The tools were registered in the API tool payload + // but `buildSystemPrompt` filtered its "You have access to the following + // tools" list through TOOL_DESCRIPTIONS, which lacked send_to_tab/read_tab + // — so the model was told it didn't have them and refused to use them. This + // locks the prompt's capability list to the granted toolset. + describe("send_to_tab / read_tab system-prompt advertisement", () => { + async function promptForPerms(tabId: string, perms: Record<string, string>): Promise<string> { + for (const [k, v] of Object.entries(perms)) setFakeSetting(k, v); + const manager = new AgentManager(); + await manager.processMessage(tabId, "go"); + return constructedAgents.at(-1)?.systemPrompt ?? ""; + } + + it("lists send_to_tab in the system prompt when granted", async () => { + const prompt = await promptForPerms("tab-prompt-send", { perm_send_to_tab: "allow" }); + expect(prompt).toContain("- send_to_tab:"); + expect(prompt).not.toContain("- read_tab:"); + }); + + it("lists read_tab in the system prompt when granted", async () => { + const prompt = await promptForPerms("tab-prompt-read", { perm_read_tab: "allow" }); + expect(prompt).toContain("- read_tab:"); + expect(prompt).not.toContain("- send_to_tab:"); + }); + + it("lists both tab-messaging tools when both are granted", async () => { + const prompt = await promptForPerms("tab-prompt-both", { + perm_send_to_tab: "allow", + perm_read_tab: "allow", + }); + expect(prompt).toContain("- send_to_tab:"); + expect(prompt).toContain("- read_tab:"); + }); + + it("omits both from the system prompt when neither is granted", async () => { + const prompt = await promptForPerms("tab-prompt-neither", {}); + expect(prompt).not.toContain("- send_to_tab:"); + expect(prompt).not.toContain("- read_tab:"); + }); + + it("advertises exactly the granted tab tools (prompt list matches schema)", async () => { + for (const [k, v] of Object.entries({ + perm_send_to_tab: "allow", + perm_read_tab: "allow", + })) { + setFakeSetting(k, v); + } + const manager = new AgentManager(); + await manager.processMessage("tab-prompt-match", "go"); + const inst = constructedAgents.at(-1); + // Every granted tab-messaging tool surfaced in the schema must also be + // advertised in the prompt, so the model never believes it lacks one. + for (const name of ["send_to_tab", "read_tab"]) { + expect(inst?.toolNames).toContain(name); + expect(inst?.systemPrompt).toContain(`- ${name}:`); + } + }); + }); + // ─── Usage side-channel persistence ────────────────────────────── // // `usage` AgentEvents (one per LLM round-trip) are persisted as invisible diff --git a/packages/api/tests/routes.test.ts b/packages/api/tests/routes.test.ts index c85d43d..37c19ca 100644 --- a/packages/api/tests/routes.test.ts +++ b/packages/api/tests/routes.test.ts @@ -82,6 +82,36 @@ vi.mock("@dispatch/core", () => ({ execute: async () => ["file1.ts"], }; }, + createLspTool(_getContext: unknown): ToolDefinition { + return { + name: "lsp", + description: "query the language server", + parameters: { _type: "z.ZodObject", shape: {} } as unknown as ToolDefinition["parameters"], + execute: async () => "mock lsp", + }; + }, + LspManager: class MockLspManager { + hasServerForFile() { + return false; + } + async getClients() { + return []; + } + async touchFile() {} + getDiagnostics() { + return {}; + } + async request() { + return []; + } + async shutdownAll() {} + }, + resolveServersFromConfig(_lsp: unknown) { + return []; + }, + reportDiagnostics(_file: string, _issues: unknown) { + return ""; + }, createRunShellTool(_wd: string): ToolDefinition { return { name: "run_shell", |
