summaryrefslogtreecommitdiffhomepage
path: root/packages/api
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-02 16:06:13 +0900
committerAdam Malczewski <[email protected]>2026-06-02 16:06:13 +0900
commitb3aca3efe9e8cda79db6e2c7fa20482880ed16c3 (patch)
tree3480c1e670d78040bb03a9ec930d815575efc463 /packages/api
parent1541e8d9ecc305bb27cf004cb919ef9065eca8be (diff)
parent2b57c1af0247954ccf57d9ba3b0f4a45502ef3da (diff)
downloaddispatch-b3aca3efe9e8cda79db6e2c7fa20482880ed16c3.tar.gz
dispatch-b3aca3efe9e8cda79db6e2c7fa20482880ed16c3.zip
Merge branch 'dev' into feat/plus-button-sticky
Diffstat (limited to 'packages/api')
-rw-r--r--packages/api/src/agent-manager.ts51
-rw-r--r--packages/api/tests/agent-manager.test.ts132
2 files changed, 167 insertions, 16 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts
index 85dd160..2795a6c 100644
--- a/packages/api/src/agent-manager.ts
+++ b/packages/api/src/agent-manager.ts
@@ -83,6 +83,10 @@ const TOOL_DESCRIPTIONS: Record<string, string> = {
web_search: "Search the web and optionally scrape full page content from results.",
youtube_transcribe:
"Fetch the transcript/subtitles for a YouTube video. Set background=true to start in the background and get a job_id for later retrieval.",
+ send_to_tab:
+ "Send a message to another tab (agent) by its short ID, as shown in the tab bar. Fire-and-forget: it queues/wakes the target and returns immediately without waiting for a reply. Do NOT sleep, poll, or run commands to wait — if the target replies it will wake you with a new message in a later turn; if you are only waiting, end your turn.",
+ read_tab:
+ "Read another tab (agent)'s most recent completed response by its short ID. Returns a non-blocking snapshot; if the target is still running you get its previous completed turn. Use after send_to_tab to collect a reply.",
};
/**
@@ -542,7 +546,7 @@ export class AgentManager {
}
// Tab-to-tab communication — gated on the child whitelist.
if (allowed.has("send_to_tab") || allowed.has("read_tab")) {
- for (const entry of this.buildTabCommToolEntries(tabId)) {
+ for (const entry of this.buildTabCommToolEntries(tabId, allowed.has("read_tab"))) {
if (allowed.has(entry.name)) toolEntries.push(entry);
}
}
@@ -575,7 +579,13 @@ export class AgentManager {
});
}
toolEntries.push({ name: "todo", tool: createTaskListTool(tabAgent.taskList) });
- if (permSummon) {
+ // The `summon` tool is registered when EITHER the subagent
+ // permission (`perm_summon`) OR the user-agent permission
+ // (`perm_user_agent`) is granted — the two are independent.
+ // `perm_summon` enables ordinary subagent spawning; granting
+ // only `perm_user_agent` exposes summon in user-agent-only mode
+ // (spawns top-level user agents exclusively).
+ if (permSummon || permUserAgent) {
// Capture parent's allowed tool names for child permission enforcement
const parentAllowedTools = new Set(toolEntries.map((e) => e.name));
const allAgentDefs = loadAgents(workingDirectory);
@@ -609,25 +619,31 @@ export class AgentManager {
availableUserAgents,
agentDirPaths,
permUserAgent,
+ permSummon,
),
});
- toolEntries.push({
- name: "retrieve",
- tool: createRetrieveTool({
- getResult: (id) =>
- tabAgent.shellStore.has(id)
- ? tabAgent.shellStore.getResult(id)
- : tabAgent.transcriptStore.has(id)
- ? tabAgent.transcriptStore.getResult(id)
- : this.getChildResult(id),
- }),
- });
+ // `retrieve` collects subagent results. User agents are
+ // fire-and-forget, so it is bundled with the subagent
+ // permission only — a user-agent-only grant doesn't get it.
+ if (permSummon) {
+ toolEntries.push({
+ name: "retrieve",
+ tool: createRetrieveTool({
+ getResult: (id) =>
+ tabAgent.shellStore.has(id)
+ ? tabAgent.shellStore.getResult(id)
+ : tabAgent.transcriptStore.has(id)
+ ? tabAgent.transcriptStore.getResult(id)
+ : this.getChildResult(id),
+ }),
+ });
+ }
}
if (permSendToTab || permReadTab) {
const tabCommAllowed = new Set<string>();
if (permSendToTab) tabCommAllowed.add("send_to_tab");
if (permReadTab) tabCommAllowed.add("read_tab");
- for (const entry of this.buildTabCommToolEntries(tabId)) {
+ for (const entry of this.buildTabCommToolEntries(tabId, permReadTab)) {
if (tabCommAllowed.has(entry.name)) toolEntries.push(entry);
}
}
@@ -1237,9 +1253,15 @@ export class AgentManager {
* both tool-construction paths (child whitelist + permission-gated parent).
* `selfHandle` is computed once so the calling tab can stamp provenance and
* reject self-sends.
+ *
+ * `canReadTab` reflects whether THIS tab will also be granted `read_tab`
+ * (the permissions are split). It is forwarded into `send_to_tab` so the
+ * tool only points the agent at `read_tab` when it actually has it — never
+ * advertising a tool the agent wasn't granted.
*/
private buildTabCommToolEntries(
tabId: string,
+ canReadTab: boolean,
): Array<{ name: string; tool: ReturnType<typeof createSendToTabTool> }> {
const selfHandle = shortestUniquePrefix(tabId);
return [
@@ -1253,6 +1275,7 @@ export class AgentManager {
this.deliverMessage(targetId, message, { origin: "agent" }),
listOpenHandles: () => this.listOpenHandles(tabId),
self: { id: tabId, handle: selfHandle },
+ canReadTab,
}),
},
{
diff --git a/packages/api/tests/agent-manager.test.ts b/packages/api/tests/agent-manager.test.ts
index 014022a..3353aff 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) {
@@ -319,6 +326,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;
@@ -1441,6 +1464,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