summaryrefslogtreecommitdiffhomepage
path: root/packages/core
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
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')
-rw-r--r--packages/core/src/agents/loader.ts4
-rw-r--r--packages/core/src/db/tabs.ts75
-rw-r--r--packages/core/src/index.ts11
-rw-r--r--packages/core/src/llm/debug-logger.ts21
-rw-r--r--packages/core/src/tools/read-tab.ts95
-rw-r--r--packages/core/src/tools/send-to-tab.ts147
-rw-r--r--packages/core/src/tools/summon.ts8
-rw-r--r--packages/core/tests/agents/loader.test.ts28
-rw-r--r--packages/core/tests/db/tabs.test.ts119
-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
12 files changed, 739 insertions, 22 deletions
diff --git a/packages/core/src/agents/loader.ts b/packages/core/src/agents/loader.ts
index 333716e..f4a6c5a 100644
--- a/packages/core/src/agents/loader.ts
+++ b/packages/core/src/agents/loader.ts
@@ -108,8 +108,8 @@ export function expandAgentToolNames(tools: string[]): string[] {
default:
// Pass through tool names that aren't permission-group
// aliases (summon, retrieve, web_search, youtube_transcribe,
- // todo, and the granular file tools themselves if a user
- // hand-wrote them in a TOML).
+ // send_to_tab, read_tab, todo, and the granular file tools
+ // themselves if a user hand-wrote them in a TOML).
expanded.add(t);
}
}
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
index 928f434..8b290d2 100644
--- a/packages/core/src/db/tabs.ts
+++ b/packages/core/src/db/tabs.ts
@@ -159,3 +159,78 @@ export function getDescendantIds(rootId: string): string[] {
}
return order.reverse();
}
+
+/**
+ * Minimum length of a tab-handle prefix accepted by `resolveTabPrefix`.
+ * Mirrors the frontend's minimum DISPLAY length (4 hex chars). Anything
+ * shorter is rejected as too broad — an agent must echo at least the 4-char
+ * handle shown in the UI.
+ */
+export const MIN_TAB_PREFIX_LENGTH = 4;
+
+/**
+ * Outcome of resolving a short tab handle (a git-style prefix of a tab's
+ * UUID) back to a concrete open tab.
+ *
+ * - `ok` — exactly one open tab matched; `tab` is it.
+ * - `none` — no open tab matched (bad/stale handle, or too-short prefix).
+ * - `ambiguous` — more than one open tab shares the prefix; `matches` lists
+ * them so the caller can ask for one more character (the same
+ * UX as `git checkout <ambiguous-sha>`).
+ */
+export type ResolveTabPrefixResult =
+ | { status: "ok"; tab: TabRow }
+ | { status: "none" }
+ | { status: "ambiguous"; matches: TabRow[] };
+
+/**
+ * Resolve a short tab handle to a single OPEN tab by prefix match — the
+ * git-short-hash model. The handle is NEVER stored: it is always derived from
+ * (and matched against) the canonical lowercase UUID in `tabs.id`.
+ *
+ * Sanitization is mandatory because the SQLite `LIKE` operator treats `%` and
+ * `_` as wildcards: an unsanitized prefix like `a%` would match broadly. We
+ * lowercase the input (UUIDs are canonical lowercase; SQLite `LIKE` is also
+ * ASCII-case-insensitive by default) and strip everything outside the UUID
+ * alphabet `[0-9a-f-]` so no wildcard can survive into the query.
+ *
+ * A prefix shorter than `MIN_TAB_PREFIX_LENGTH` after sanitization returns
+ * `none` rather than matching a large swath of tabs.
+ *
+ * Only OPEN tabs (`is_open = 1`) are addressable — a closed tab's UUID prefix
+ * must not cause phantom ambiguity or resolve to a dead conversation.
+ */
+export function resolveTabPrefix(prefix: string): ResolveTabPrefixResult {
+ const sanitized = (prefix ?? "").toLowerCase().replace(/[^0-9a-f-]/g, "");
+ if (sanitized.length < MIN_TAB_PREFIX_LENGTH) {
+ return { status: "none" };
+ }
+ const db = getDatabase();
+ const rows = db
+ .query("SELECT * FROM tabs WHERE is_open = 1 AND id LIKE $prefix ORDER BY position ASC")
+ .all({ $prefix: `${sanitized}%` }) as Array<Record<string, unknown>>;
+ if (rows.length === 0) return { status: "none" };
+ if (rows.length === 1) return { status: "ok", tab: rowToTab(rows[0] as Record<string, unknown>) };
+ return { status: "ambiguous", matches: rows.map(rowToTab) };
+}
+
+/**
+ * Compute the shortest unique prefix (minimum `MIN_TAB_PREFIX_LENGTH` chars)
+ * that identifies `tabId` among the currently OPEN tabs — the backend twin of
+ * the frontend's display helper. Used when a tool needs to echo a tab's own
+ * handle (e.g. provenance prefixes, "available tabs" hints) without trusting a
+ * value from the wire.
+ *
+ * Returns the full id if no shorter unique prefix exists (degenerate — only if
+ * two open tabs share an entire id, which UUID uniqueness precludes).
+ */
+export function shortestUniquePrefix(tabId: string): string {
+ const db = getDatabase();
+ const rows = db.query("SELECT id FROM tabs WHERE is_open = 1").all() as Array<{ id: string }>;
+ const others = rows.map((r) => r.id).filter((id) => id !== tabId);
+ for (let len = MIN_TAB_PREFIX_LENGTH; len < tabId.length; len++) {
+ const candidate = tabId.slice(0, len);
+ if (!others.some((id) => id.startsWith(candidate))) return candidate;
+ }
+ return tabId;
+}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index a3816ea..b1b17cc 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -49,6 +49,10 @@ export {
createTab,
getTab,
listOpenTabs,
+ MIN_TAB_PREFIX_LENGTH,
+ type ResolveTabPrefixResult,
+ resolveTabPrefix,
+ shortestUniquePrefix,
type TabRow,
updateTabModel,
updateTabStatus,
@@ -78,9 +82,16 @@ export { prefix as bashArityPrefix } from "./tools/bash-arity.js";
export { createListFilesTool } from "./tools/list-files.js";
export { createReadFileTool } from "./tools/read-file.js";
export { createReadFileSliceTool } from "./tools/read-file-slice.js";
+export { createReadTabTool, type ReadTabCallbacks } from "./tools/read-tab.js";
export { createToolRegistry } from "./tools/registry.js";
export { createRetrieveTool, type RetrieveCallbacks } from "./tools/retrieve.js";
export { BackgroundShellStore, createRunShellTool } from "./tools/run-shell.js";
+export {
+ createSendToTabTool,
+ type ResolvedTabRef,
+ type SendToTabCallbacks,
+ type TabResolution,
+} from "./tools/send-to-tab.js";
export { analyzeCommand } from "./tools/shell-analyze.js";
export {
type AvailableAgent,
diff --git a/packages/core/src/llm/debug-logger.ts b/packages/core/src/llm/debug-logger.ts
index 2b7420c..072a7a1 100644
--- a/packages/core/src/llm/debug-logger.ts
+++ b/packages/core/src/llm/debug-logger.ts
@@ -281,11 +281,7 @@ export function logStepLifecycle(data: {
* Log agent loop-level events (loop start, break conditions, etc.).
* Only logs at verbosity >= 3.
*/
-export function logAgentLoop(data: {
- tabId?: string;
- event: string;
- detail?: unknown;
-}): void {
+export function logAgentLoop(data: { tabId?: string; event: string; detail?: unknown }): void {
if (!ENABLED || VERBOSITY < 3) return;
const detail = data.detail !== undefined ? ` ${JSON.stringify(data.detail)}` : "";
console.error(`[dispatch-debug] AGENT tab=${data.tabId ?? "?"} ${data.event}${detail}`);
@@ -308,16 +304,14 @@ export function logAgentLoop(data: {
* we just clone and read once via `.text()` — simpler and safe because
* non-streaming bodies are bounded.
*/
-export function wrapFetchWithLogging<
- F extends (...args: never[]) => Promise<Response> | Response,
->(baseFetch: F, opts: { tabId?: string; modelHint?: string }): F {
+export function wrapFetchWithLogging<F extends (...args: never[]) => Promise<Response> | Response>(
+ baseFetch: F,
+ opts: { tabId?: string; modelHint?: string },
+): F {
if (!ENABLED) return baseFetch;
const wrapped = async (...args: Parameters<F>) => {
const requestId = ++seq;
- const [input, init] = args as unknown as [
- RequestInfo | URL,
- RequestInit | undefined,
- ];
+ const [input, init] = args as unknown as [RequestInfo | URL, RequestInit | undefined];
const url =
typeof input === "string"
? input
@@ -325,7 +319,8 @@ export function wrapFetchWithLogging<
? input.toString()
: (input as Request).url;
const method =
- init?.method ?? (typeof input === "object" && "method" in input ? (input as Request).method : "POST");
+ init?.method ??
+ (typeof input === "object" && "method" in input ? (input as Request).method : "POST");
// Snapshot headers as a plain object for logging.
const headerObj: Record<string, string> = {};
diff --git a/packages/core/src/tools/read-tab.ts b/packages/core/src/tools/read-tab.ts
new file mode 100644
index 0000000..e80dbd0
--- /dev/null
+++ b/packages/core/src/tools/read-tab.ts
@@ -0,0 +1,95 @@
+import { z } from "zod";
+import type { AgentStatus, ToolDefinition } from "../types/index.js";
+import type { TabResolution } from "./send-to-tab.js";
+
+export interface ReadTabCallbacks {
+ /** Resolve a (possibly short) handle to one open tab. */
+ resolveShortId(prefix: string): TabResolution;
+ /**
+ * Return the target tab's most recent COMPLETED assistant turn as plain
+ * text, plus its current status. `text` is null when the tab has no
+ * completed assistant turn yet.
+ */
+ getLastResponse(tabId: string): { text: string | null; status: AgentStatus };
+ /** Snapshot of currently-open tabs, for "available tabs" error hints. */
+ listOpenHandles(): Array<{ handle: string; title: string }>;
+}
+
+/** Render the "available tabs" hint shared by the none/ambiguous branches. */
+function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
+ if (handles.length === 0) return "No other tabs are currently open.";
+ const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
+ return ["Currently open tabs:", ...lines].join("\n");
+}
+
+export function createReadTabTool(callbacks: ReadTabCallbacks): ToolDefinition {
+ return {
+ name: "read_tab",
+ description: [
+ "Read the most recent completed response from another tab (agent) by its short ID.",
+ "",
+ "Returns a SNAPSHOT — it does NOT block or wait for the target to finish.",
+ " - If the target is idle, you get its just-finished turn.",
+ " - If the target is still running, you get its PREVIOUS completed turn (if any);",
+ " call read_tab again later to get the newest one.",
+ "",
+ "Use this after send_to_tab to collect another agent's reply. IDs are git-style",
+ "prefixes: pass any length that uniquely identifies the target (min 4 chars).",
+ ].join("\n"),
+ parameters: z.object({
+ tab_id: z
+ .string()
+ .describe(
+ "The short ID (handle) of the tab to read, as shown in the tab bar. Any unique-length prefix works (min 4 chars).",
+ ),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
+
+ if (!rawId) {
+ return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
+ }
+
+ const resolution = callbacks.resolveShortId(rawId);
+
+ if (resolution.status === "none") {
+ return [
+ `Error: no open tab matches the ID "${rawId}".`,
+ "",
+ renderOpenHandles(callbacks.listOpenHandles()),
+ ].join("\n");
+ }
+ if (resolution.status === "ambiguous") {
+ const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
+ return [
+ `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
+ matches,
+ "",
+ "Add one or more characters to disambiguate.",
+ ].join("\n");
+ }
+
+ const target = resolution.tab;
+ const { text, status } = callbacks.getLastResponse(target.id);
+
+ const runningNote =
+ status === "running"
+ ? " (this tab is still running; the response below is its previous completed turn — read again later for the newest)"
+ : "";
+
+ if (text === null) {
+ const reason =
+ status === "running"
+ ? "it is still working on its first turn"
+ : "it has no assistant responses yet";
+ return `Tab ${target.handle} (${target.title}) has no completed response — ${reason}.`;
+ }
+
+ return [
+ `<tab_response tab="${target.handle}" status="${status}"${runningNote}>`,
+ text,
+ "</tab_response>",
+ ].join("\n");
+ },
+ };
+}
diff --git a/packages/core/src/tools/send-to-tab.ts b/packages/core/src/tools/send-to-tab.ts
new file mode 100644
index 0000000..eb86b7e
--- /dev/null
+++ b/packages/core/src/tools/send-to-tab.ts
@@ -0,0 +1,147 @@
+import { z } from "zod";
+import type { ToolDefinition } from "../types/index.js";
+
+/**
+ * A tab reference surfaced to the `send_to_tab` / `read_tab` tools. The tools
+ * are intentionally decoupled from the DB `TabRow` shape — the AgentManager
+ * maps `resolveTabPrefix(...)` results down to this minimal projection so the
+ * tools (and their unit tests) never depend on the persistence layer.
+ */
+export interface ResolvedTabRef {
+ /** The tab's canonical full UUID. */
+ id: string;
+ /** The tab's display title (for disambiguation hints). */
+ title: string;
+ /** The tab's current short handle (shortest unique prefix). */
+ handle: string;
+}
+
+/**
+ * Outcome of resolving a short tab handle. Mirrors core's
+ * `ResolveTabPrefixResult` but over the minimal `ResolvedTabRef` projection.
+ */
+export type TabResolution =
+ | { status: "ok"; tab: ResolvedTabRef }
+ | { status: "none" }
+ | { status: "ambiguous"; matches: ResolvedTabRef[] };
+
+export interface SendToTabCallbacks {
+ /** Resolve a (possibly short) handle to one open tab. */
+ resolveShortId(prefix: string): TabResolution;
+ /**
+ * Deliver `message` to `tabId`. If the target is mid-turn the message is
+ * queued (same path as a user message); if idle/errored it wakes the tab
+ * and starts a new turn. Returns quickly — does NOT block on the turn.
+ */
+ deliver(
+ tabId: string,
+ message: string,
+ ):
+ | Promise<{ status: "queued" | "started" | "suppressed" }>
+ | { status: "queued" | "started" | "suppressed" };
+ /** Snapshot of currently-open tabs, for "available tabs" error hints. */
+ listOpenHandles(): Array<{ handle: string; title: string }>;
+ /** The calling tab's own id + handle — used to block self-sends and to
+ * stamp provenance onto the delivered message. */
+ self: { id: string; handle: string };
+}
+
+/** Render the "available tabs" hint shared by the none/ambiguous branches. */
+function renderOpenHandles(handles: Array<{ handle: string; title: string }>): string {
+ if (handles.length === 0) return "No other tabs are currently open.";
+ const lines = handles.map((h) => ` - ${h.handle}: ${h.title}`);
+ return ["Currently open tabs:", ...lines].join("\n");
+}
+
+export function createSendToTabTool(callbacks: SendToTabCallbacks): ToolDefinition {
+ return {
+ name: "send_to_tab",
+ description: [
+ "Send a message to another tab (agent) by its short ID — the handle shown in the tab bar.",
+ "",
+ "Behaviour mirrors a user sending a message:",
+ " - If the target tab is mid-turn (busy), your message is QUEUED and picked up next.",
+ " - If the target tab is idle, your message WAKES it and starts a new turn.",
+ "",
+ "This is fire-and-forget: it returns immediately and does NOT wait for a reply.",
+ "Use the 'read_tab' tool with the same ID later to read the target's latest response.",
+ "",
+ "Your tab ID is auto-added to the top of the message so the recipient can reply to you.",
+ "IDs are git-style prefixes: pass any length that uniquely identifies the target (min 4 chars).",
+ "If the ID is ambiguous you'll be asked to add a character.",
+ ].join("\n"),
+ parameters: z.object({
+ tab_id: z
+ .string()
+ .describe(
+ "The short ID (handle) of the target tab, as shown in the tab bar. Any unique-length prefix of the tab's id works (min 4 chars).",
+ ),
+ message: z
+ .string()
+ .describe("The message to deliver to the target tab, exactly as a user would type it."),
+ }),
+ execute: async (args: Record<string, unknown>): Promise<string> => {
+ const rawId = (args.tab_id as string | undefined)?.trim() ?? "";
+ const message = (args.message as string | undefined) ?? "";
+
+ if (!rawId) {
+ return `Error: tab_id is required.\n\n${renderOpenHandles(callbacks.listOpenHandles())}`;
+ }
+ if (!message.trim()) {
+ return "Error: message must not be empty.";
+ }
+
+ const resolution = callbacks.resolveShortId(rawId);
+
+ if (resolution.status === "none") {
+ return [
+ `Error: no open tab matches the ID "${rawId}".`,
+ "",
+ renderOpenHandles(callbacks.listOpenHandles()),
+ ].join("\n");
+ }
+ if (resolution.status === "ambiguous") {
+ const matches = resolution.matches.map((m) => ` - ${m.handle}: ${m.title}`).join("\n");
+ return [
+ `Error: the ID "${rawId}" is ambiguous — it matches multiple open tabs:`,
+ matches,
+ "",
+ "Add one or more characters to disambiguate.",
+ ].join("\n");
+ }
+
+ const target = resolution.tab;
+
+ if (target.id === callbacks.self.id) {
+ return "Error: cannot send a message to your own tab.";
+ }
+
+ // Stamp provenance so the recipient (and the watching user) can see
+ // which tab the message came from and reply back via its handle.
+ const delivered = `[message from tab ${callbacks.self.handle}]\n\n${message}`;
+
+ try {
+ const result = await callbacks.deliver(target.id, delivered);
+ if (result.status === "suppressed") {
+ // The target hit its automatic agent-to-agent wake limit. The
+ // message was preserved (queued) but did NOT start a turn — a
+ // human must step in. Tell the sender plainly so it stops
+ // hammering the target and creating a runaway loop.
+ return [
+ `Message HELD for tab ${target.handle} (${target.title}) — it was NOT delivered as a wake.`,
+ `That tab has reached its automatic agent-to-agent message limit, so it will not`,
+ `auto-respond again until a human sends it a message. Do not keep resending:`,
+ `your message is already queued and will be seen when a human resumes that tab.`,
+ ].join("\n");
+ }
+ const verb =
+ result.status === "queued"
+ ? "queued (target is busy; it will be picked up next turn)"
+ : "delivered (target was idle; a new turn has started)";
+ return `Message ${verb}. Target tab: ${target.handle} (${target.title}). Use read_tab with "${target.handle}" to read its reply later.`;
+ } catch (err) {
+ return `Error delivering message: ${err instanceof Error ? err.message : String(err)}`;
+ }
+ },
+ };
+}
diff --git a/packages/core/src/tools/summon.ts b/packages/core/src/tools/summon.ts
index d40f0ca..4820e89 100644
--- a/packages/core/src/tools/summon.ts
+++ b/packages/core/src/tools/summon.ts
@@ -177,6 +177,8 @@ export function createSummonTool(
" - retrieve: Collect results from its children (required if summon is given)",
" - web_search: Search the web",
" - youtube_transcribe: Fetch YouTube video transcripts",
+ " - send_to_tab: Send a message to another tab/agent by its ID",
+ " - read_tab: Read another tab/agent's latest response by its ID",
"",
"The 'agent' parameter is required — every spawned agent must use a definition.",
"Tools default to the agent definition's tools, intersected with your own tools (you can't grant capabilities you don't have).",
@@ -232,6 +234,8 @@ export function createSummonTool(
"retrieve",
"web_search",
"youtube_transcribe",
+ "send_to_tab",
+ "read_tab",
]),
)
.optional()
@@ -262,7 +266,9 @@ export function createSummonTool(
const tools = args.tools as string[] | undefined;
const workingDirectory = args.working_directory as string | undefined;
const background = (args.background as boolean | undefined) ?? false;
- const topLevel = userAgentEnabled ? ((args.top_level as boolean | undefined) ?? false) : false;
+ const topLevel = userAgentEnabled
+ ? ((args.top_level as boolean | undefined) ?? false)
+ : false;
try {
const agentId = await callbacks.spawn({
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");