diff options
| author | Adam Malczewski <[email protected]> | 2026-06-01 01:46:13 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-01 01:46:13 +0900 |
| commit | 8b9533c22a47bbf6f916667e2c25d8e8e419da37 (patch) | |
| tree | 715a6a3d6f43781395e7dc7c8cdb519cef46a870 /packages/core/tests | |
| parent | 1853dd1d40308deb829bc621beb79c5d39b9c57f (diff) | |
| download | dispatch-8b9533c22a47bbf6f916667e2c25d8e8e419da37.tar.gz dispatch-8b9533c22a47bbf6f916667e2c25d8e8e419da37.zip | |
feat(tabs): tab-to-tab agent communication via short handles
Add send_to_tab / read_tab tools so an agent can message or read another
tab by a git-style short handle (shortest unique prefix of the tab UUID,
min 4 chars), shown in the tab bar.
- core/db/tabs: resolveTabPrefix + shortestUniquePrefix (open tabs only,
LIKE-sanitized prefix matching)
- new tools read-tab.ts / send-to-tab.ts (+ tests) decoupled from the DB
TabRow via a minimal ResolvedTabRef projection
- agent-manager: unified deliverMessage routing (busy -> queue, idle ->
new turn) shared by POST /chat and send_to_tab; agent->agent auto-wake
budget (MAX_AGENT_AUTO_WAKES) to bound ping-pong loops
- summon/loader: send_to_tab + read_tab as grantable tools
- frontend: shortHandleFor + handle badge in TabBar; perm toggles
- notes: tab-comm / user-agents / todo-redesign plans
- chore: biome format fixes (debug-logger, summon.test)
Refs notes/plan-tab-comm.md
Diffstat (limited to 'packages/core/tests')
| -rw-r--r-- | packages/core/tests/agents/loader.test.ts | 28 | ||||
| -rw-r--r-- | packages/core/tests/db/tabs.test.ts | 119 | ||||
| -rw-r--r-- | packages/core/tests/tools/read-tab.test.ts | 101 | ||||
| -rw-r--r-- | packages/core/tests/tools/send-to-tab.test.ts | 142 | ||||
| -rw-r--r-- | packages/core/tests/tools/summon.test.ts | 10 |
5 files changed, 394 insertions, 6 deletions
diff --git a/packages/core/tests/agents/loader.test.ts b/packages/core/tests/agents/loader.test.ts index 88173ea..35ab0cf 100644 --- a/packages/core/tests/agents/loader.test.ts +++ b/packages/core/tests/agents/loader.test.ts @@ -22,10 +22,34 @@ describe("expandAgentToolNames", () => { expect(out).toContain("run_shell"); }); + it("passes through the tab tools as independent names (no tab_comm group)", () => { + const out = expandAgentToolNames(["send_to_tab", "read_tab"]); + expect(out).toContain("send_to_tab"); + expect(out).toContain("read_tab"); + // Granting only one must not pull in the other. + const onlySend = expandAgentToolNames(["send_to_tab"]); + expect(onlySend).toContain("send_to_tab"); + expect(onlySend).not.toContain("read_tab"); + }); + it("passes through non-group tool names unchanged", () => { - const out = expandAgentToolNames(["summon", "retrieve", "web_search", "youtube_transcribe"]); + const out = expandAgentToolNames([ + "summon", + "retrieve", + "web_search", + "youtube_transcribe", + "send_to_tab", + "read_tab", + ]); expect(out).toEqual( - expect.arrayContaining(["summon", "retrieve", "web_search", "youtube_transcribe"]), + expect.arrayContaining([ + "summon", + "retrieve", + "web_search", + "youtube_transcribe", + "send_to_tab", + "read_tab", + ]), ); }); diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts index e1c9bf8..67533dc 100644 --- a/packages/core/tests/db/tabs.test.ts +++ b/packages/core/tests/db/tabs.test.ts @@ -73,6 +73,22 @@ class FakeDatabase { return [{ max_pos: maxPos }]; } + // resolveTabPrefix: open tabs whose id starts with a sanitized prefix. + // The production query binds `$prefix` as `<sanitized>%`; emulate SQLite + // LIKE prefix semantics here (case-insensitive, `%` = "rest of string"). + if (norm === "SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC") { + const raw = String(params?.$prefix ?? ""); + const needle = raw.endsWith("%") ? raw.slice(0, -1) : raw; + return this.rows + .filter((r) => r.is_open === 1 && r.id.toLowerCase().startsWith(needle.toLowerCase())) + .sort((a, b) => a.position - b.position); + } + + // shortestUniquePrefix: all open tab ids. + if (norm === "SELECT id FROM tabs WHERE is_open = 1") { + return this.rows.filter((r) => r.is_open === 1).map((r) => ({ id: r.id })); + } + throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`); } @@ -134,7 +150,8 @@ vi.mock("../../src/db/index.js", () => ({ // Dynamic import AFTER `vi.mock` registers (vitest hoists `vi.mock` to // the very top of the file, so by the time this line runs the mock is // active for `./index.js` resolution inside `tabs.ts`). -const { archiveTab, createTab, getDescendantIds, getTab } = await import("../../src/db/tabs.js"); +const { archiveTab, createTab, getDescendantIds, getTab, resolveTabPrefix, shortestUniquePrefix } = + await import("../../src/db/tabs.js"); beforeAll(() => { fakeDb = new FakeDatabase(); @@ -234,3 +251,103 @@ describe("getDescendantIds", () => { expect(ids2).toEqual(["b1", "a1"]); }); }); + +// --------------------------------------------------------------------------- +// resolveTabPrefix — git-style short-handle resolution +// --------------------------------------------------------------------------- +describe("resolveTabPrefix", () => { + it("returns none when the prefix is shorter than the minimum length", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "A"); + // 3 chars < MIN_TAB_PREFIX_LENGTH (4) + expect(resolveTabPrefix("abc").status).toBe("none"); + }); + + it("returns none when no open tab matches", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "A"); + expect(resolveTabPrefix("ffff").status).toBe("none"); + }); + + it("resolves a unique 4-char prefix to the single matching tab", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + createTab("9999aaaa-0000-4000-8000-000000000000", "Beta"); + const res = resolveTabPrefix("abcd"); + expect(res.status).toBe("ok"); + if (res.status === "ok") { + expect(res.tab.id).toBe("abcd1234-0000-4000-8000-000000000000"); + expect(res.tab.title).toBe("Alpha"); + } + }); + + it("resolves the full UUID (a maximal prefix)", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + const res = resolveTabPrefix("abcd1234-0000-4000-8000-000000000000"); + expect(res.status).toBe("ok"); + }); + + it("reports ambiguity when multiple open tabs share the prefix", () => { + createTab("abcd1111-0000-4000-8000-000000000000", "One"); + createTab("abcd2222-0000-4000-8000-000000000000", "Two"); + const res = resolveTabPrefix("abcd"); + expect(res.status).toBe("ambiguous"); + if (res.status === "ambiguous") { + expect(res.matches).toHaveLength(2); + expect(res.matches.map((m) => m.title).sort()).toEqual(["One", "Two"]); + } + }); + + it("disambiguates when one more character is supplied", () => { + createTab("abcd1111-0000-4000-8000-000000000000", "One"); + createTab("abcd2222-0000-4000-8000-000000000000", "Two"); + const res = resolveTabPrefix("abcd1"); + expect(res.status).toBe("ok"); + if (res.status === "ok") expect(res.tab.title).toBe("One"); + }); + + it("matches case-insensitively (UUIDs are lowercase; LIKE is ASCII-CI)", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + const res = resolveTabPrefix("ABCD"); + expect(res.status).toBe("ok"); + }); + + it("sanitizes LIKE wildcards so they cannot broaden the match", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + createTab("9999aaaa-0000-4000-8000-000000000000", "Beta"); + // `%` would match everything if not stripped; after sanitization the + // query is effectively `abcd%` which matches only Alpha. + const res = resolveTabPrefix("ab%d"); + // "ab%d" -> sanitized "abd" (3 chars) -> below min length -> none. + expect(res.status).toBe("none"); + }); + + it("excludes archived (closed) tabs from matches", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + archiveTab("abcd1234-0000-4000-8000-000000000000"); + expect(resolveTabPrefix("abcd").status).toBe("none"); + }); +}); + +// --------------------------------------------------------------------------- +// shortestUniquePrefix — display-handle derivation +// --------------------------------------------------------------------------- +describe("shortestUniquePrefix", () => { + it("returns a 4-char prefix when no other open tab collides", () => { + createTab("abcd1234-0000-4000-8000-000000000000", "Alpha"); + expect(shortestUniquePrefix("abcd1234-0000-4000-8000-000000000000")).toBe("abcd"); + }); + + it("grows the prefix one char at a time on a collision", () => { + createTab("abcd1111-0000-4000-8000-000000000000", "One"); + createTab("abcd2222-0000-4000-8000-000000000000", "Two"); + // First differing char is at index 4, so a 5-char prefix is unique. + expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd1"); + expect(shortestUniquePrefix("abcd2222-0000-4000-8000-000000000000")).toBe("abcd2"); + }); + + it("ignores closed tabs when computing uniqueness", () => { + createTab("abcd1111-0000-4000-8000-000000000000", "One"); + createTab("abcd2222-0000-4000-8000-000000000000", "Two"); + archiveTab("abcd2222-0000-4000-8000-000000000000"); + // With Two closed, One no longer collides → back to 4 chars. + expect(shortestUniquePrefix("abcd1111-0000-4000-8000-000000000000")).toBe("abcd"); + }); +}); diff --git a/packages/core/tests/tools/read-tab.test.ts b/packages/core/tests/tools/read-tab.test.ts new file mode 100644 index 0000000..71e419c --- /dev/null +++ b/packages/core/tests/tools/read-tab.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it } from "vitest"; +import { createReadTabTool, type ReadTabCallbacks } from "../../src/tools/read-tab.js"; +import type { TabResolution } from "../../src/tools/send-to-tab.js"; + +function makeCallbacks(overrides: Partial<ReadTabCallbacks> = {}): ReadTabCallbacks { + return { + resolveShortId: (): TabResolution => ({ + status: "ok", + tab: { id: "target-id", title: "Target", handle: "targ" }, + }), + getLastResponse: () => ({ text: "the answer is 42", status: "idle" }), + listOpenHandles: () => [{ handle: "targ", title: "Target" }], + ...overrides, + }; +} + +describe("createReadTabTool — schema & description", () => { + it("is a non-blocking snapshot read", () => { + const tool = createReadTabTool(makeCallbacks()); + expect(tool.name).toBe("read_tab"); + expect(tool.description).toContain("SNAPSHOT"); + expect(tool.description.toLowerCase()).toContain("does not block"); + }); +}); + +describe("createReadTabTool — execute()", () => { + it("returns the last assistant response wrapped in a tab_response tag", async () => { + const tool = createReadTabTool(makeCallbacks()); + const out = await tool.execute({ tab_id: "targ" }); + expect(out).toContain("<tab_response"); + expect(out).toContain('tab="targ"'); + expect(out).toContain('status="idle"'); + expect(out).toContain("the answer is 42"); + expect(out).toContain("</tab_response>"); + }); + + it("notes that a running tab's response is its previous completed turn", async () => { + const tool = createReadTabTool( + makeCallbacks({ + getLastResponse: () => ({ text: "older turn", status: "running" }), + }), + ); + const out = await tool.execute({ tab_id: "targ" }); + expect(out).toContain("still running"); + expect(out).toContain("older turn"); + }); + + it("explains when a tab has no completed response yet (idle)", async () => { + const tool = createReadTabTool( + makeCallbacks({ + getLastResponse: () => ({ text: null, status: "idle" }), + }), + ); + const out = await tool.execute({ tab_id: "targ" }); + expect(out).toContain("no completed response"); + expect(out).toContain("no assistant responses yet"); + }); + + it("explains when a tab is still on its first turn (running, no prior text)", async () => { + const tool = createReadTabTool( + makeCallbacks({ + getLastResponse: () => ({ text: null, status: "running" }), + }), + ); + const out = await tool.execute({ tab_id: "targ" }); + expect(out).toContain("no completed response"); + expect(out).toContain("still working on its first turn"); + }); + + it("rejects an empty tab_id and lists open handles", async () => { + const tool = createReadTabTool(makeCallbacks()); + const out = await tool.execute({ tab_id: "" }); + expect(out).toContain("Error"); + expect(out).toContain("targ"); + }); + + it("returns a helpful error when the id is unknown", async () => { + const tool = createReadTabTool(makeCallbacks({ resolveShortId: () => ({ status: "none" }) })); + const out = await tool.execute({ tab_id: "zzzz" }); + expect(out).toContain("no open tab matches"); + expect(out).toContain("Currently open tabs:"); + }); + + it("asks for more characters when the id is ambiguous", async () => { + const tool = createReadTabTool( + makeCallbacks({ + resolveShortId: () => ({ + status: "ambiguous", + matches: [ + { id: "a1", title: "One", handle: "abcd1" }, + { id: "a2", title: "Two", handle: "abcd2" }, + ], + }), + }), + ); + const out = await tool.execute({ tab_id: "abcd" }); + expect(out).toContain("ambiguous"); + expect(out).toContain("abcd1"); + expect(out).toContain("abcd2"); + }); +}); diff --git a/packages/core/tests/tools/send-to-tab.test.ts b/packages/core/tests/tools/send-to-tab.test.ts new file mode 100644 index 0000000..4450fc5 --- /dev/null +++ b/packages/core/tests/tools/send-to-tab.test.ts @@ -0,0 +1,142 @@ +import { describe, expect, it, vi } from "vitest"; +import { + createSendToTabTool, + type SendToTabCallbacks, + type TabResolution, +} from "../../src/tools/send-to-tab.js"; + +function makeCallbacks(overrides: Partial<SendToTabCallbacks> = {}): SendToTabCallbacks { + return { + resolveShortId: (): TabResolution => ({ + status: "ok", + tab: { id: "target-id", title: "Target", handle: "targ" }, + }), + deliver: () => ({ status: "started" }), + listOpenHandles: () => [{ handle: "targ", title: "Target" }], + self: { id: "self-id", handle: "self" }, + ...overrides, + }; +} + +describe("createSendToTabTool — schema & description", () => { + it("exposes tab_id and message params and a fire-and-forget description", () => { + const tool = createSendToTabTool(makeCallbacks()); + expect(tool.name).toBe("send_to_tab"); + expect(tool.description).toContain("fire-and-forget"); + expect(tool.description.toLowerCase()).toContain("queued"); + }); +}); + +describe("createSendToTabTool — execute()", () => { + it("delivers to a resolved target and reports the started status", async () => { + const deliver = vi.fn(() => ({ status: "started" as const })); + const tool = createSendToTabTool(makeCallbacks({ deliver })); + const out = await tool.execute({ tab_id: "targ", message: "hello there" }); + expect(deliver).toHaveBeenCalledTimes(1); + const [targetId, delivered] = deliver.mock.calls[0] ?? []; + expect(targetId).toBe("target-id"); + // Provenance prefix names the sending tab's handle. + expect(delivered).toContain("[message from tab self]"); + expect(delivered).toContain("hello there"); + expect(out).toContain("idle"); + expect(out).toContain("targ"); + }); + + it("reports the queued status when the target is busy", async () => { + const deliver = vi.fn(() => ({ status: "queued" as const })); + const tool = createSendToTabTool(makeCallbacks({ deliver })); + const out = await tool.execute({ tab_id: "targ", message: "ping" }); + expect(out.toLowerCase()).toContain("queued"); + expect(out.toLowerCase()).toContain("busy"); + }); + + it("reports a HELD message when delivery is suppressed (auto-wake limit hit)", async () => { + const deliver = vi.fn(() => ({ status: "suppressed" as const })); + const tool = createSendToTabTool(makeCallbacks({ deliver })); + const out = await tool.execute({ tab_id: "targ", message: "ping again" }); + expect(out).toContain("HELD"); + expect(out.toLowerCase()).toContain("limit"); + // It must steer the sender away from retrying in a loop. + expect(out.toLowerCase()).toContain("do not keep resending"); + expect(out.toLowerCase()).toContain("human"); + }); + + it("rejects an empty tab_id and lists open handles", async () => { + const tool = createSendToTabTool(makeCallbacks()); + const out = await tool.execute({ tab_id: " ", message: "hi" }); + expect(out).toContain("Error"); + expect(out).toContain("targ"); + }); + + it("rejects an empty message", async () => { + const deliver = vi.fn(() => ({ status: "started" as const })); + const tool = createSendToTabTool(makeCallbacks({ deliver })); + const out = await tool.execute({ tab_id: "targ", message: " " }); + expect(out).toContain("Error"); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("returns a helpful error and open-tab list when the id is unknown", async () => { + const deliver = vi.fn(() => ({ status: "started" as const })); + const tool = createSendToTabTool( + makeCallbacks({ + resolveShortId: () => ({ status: "none" }), + deliver, + }), + ); + const out = await tool.execute({ tab_id: "zzzz", message: "hi" }); + expect(out).toContain("no open tab matches"); + expect(out).toContain("Currently open tabs:"); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("asks for more characters when the id is ambiguous", async () => { + const deliver = vi.fn(() => ({ status: "started" as const })); + const tool = createSendToTabTool( + makeCallbacks({ + resolveShortId: () => ({ + status: "ambiguous", + matches: [ + { id: "a1", title: "One", handle: "abcd1" }, + { id: "a2", title: "Two", handle: "abcd2" }, + ], + }), + deliver, + }), + ); + const out = await tool.execute({ tab_id: "abcd", message: "hi" }); + expect(out).toContain("ambiguous"); + expect(out).toContain("abcd1"); + expect(out).toContain("abcd2"); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("refuses to send to its own tab", async () => { + const deliver = vi.fn(() => ({ status: "started" as const })); + const tool = createSendToTabTool( + makeCallbacks({ + resolveShortId: () => ({ + status: "ok", + tab: { id: "self-id", title: "Me", handle: "self" }, + }), + deliver, + }), + ); + const out = await tool.execute({ tab_id: "self", message: "hi" }); + expect(out).toContain("cannot send a message to your own tab"); + expect(deliver).not.toHaveBeenCalled(); + }); + + it("surfaces a thrown delivery error instead of crashing", async () => { + const tool = createSendToTabTool( + makeCallbacks({ + deliver: () => { + throw new Error("boom"); + }, + }), + ); + const out = await tool.execute({ tab_id: "targ", message: "hi" }); + expect(out).toContain("Error delivering message"); + expect(out).toContain("boom"); + }); +}); diff --git a/packages/core/tests/tools/summon.test.ts b/packages/core/tests/tools/summon.test.ts index 6597c21..f59f345 100644 --- a/packages/core/tests/tools/summon.test.ts +++ b/packages/core/tests/tools/summon.test.ts @@ -39,9 +39,13 @@ describe("createSummonTool — description content", () => { path: "/home/u/.config/dispatch/agents/researcher.toml", }, ]; - const tool = createSummonTool("/tmp/work", noopCallbacks, agents, [], [ - "/home/u/.config/dispatch/agents", - ]); + const tool = createSummonTool( + "/tmp/work", + noopCallbacks, + agents, + [], + ["/home/u/.config/dispatch/agents"], + ); expect(tool.description).toContain("programmer"); expect(tool.description).toContain("Programmer"); expect(tool.description).toContain("Implements code from a plan"); |
