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 | |
| 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')
| -rw-r--r-- | packages/core/src/agents/loader.ts | 4 | ||||
| -rw-r--r-- | packages/core/src/db/tabs.ts | 75 | ||||
| -rw-r--r-- | packages/core/src/index.ts | 11 | ||||
| -rw-r--r-- | packages/core/src/llm/debug-logger.ts | 21 | ||||
| -rw-r--r-- | packages/core/src/tools/read-tab.ts | 95 | ||||
| -rw-r--r-- | packages/core/src/tools/send-to-tab.ts | 147 | ||||
| -rw-r--r-- | packages/core/src/tools/summon.ts | 8 | ||||
| -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 |
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"); |
