summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/tools
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-01 01:46:13 +0900
committerAdam Malczewski <[email protected]>2026-06-01 01:46:13 +0900
commit8b9533c22a47bbf6f916667e2c25d8e8e419da37 (patch)
tree715a6a3d6f43781395e7dc7c8cdb519cef46a870 /packages/core/tests/tools
parent1853dd1d40308deb829bc621beb79c5d39b9c57f (diff)
downloaddispatch-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/tools')
-rw-r--r--packages/core/tests/tools/read-tab.test.ts101
-rw-r--r--packages/core/tests/tools/send-to-tab.test.ts142
-rw-r--r--packages/core/tests/tools/summon.test.ts10
3 files changed, 250 insertions, 3 deletions
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");