summaryrefslogtreecommitdiffhomepage
path: root/packages/core/tests/db
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/db
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/db')
-rw-r--r--packages/core/tests/db/tabs.test.ts119
1 files changed, 118 insertions, 1 deletions
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");
+ });
+});