summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-28 10:33:33 +0900
committerAdam Malczewski <[email protected]>2026-05-28 10:33:33 +0900
commit2eeabc95b78f6624c187e1e3892f9413266b4b9a (patch)
treeffdcd754bafb4da036b1fd3dfb22617754067641
parent1e70f2d12274da833035912206b1ac0b1ee57ef1 (diff)
downloaddispatch-2eeabc95b78f6624c187e1e3892f9413266b4b9a.tar.gz
dispatch-2eeabc95b78f6624c187e1e3892f9413266b4b9a.zip
fix(core): strip stale [USER INTERRUPT] from LLM history; inject into last tool of batch
The interrupt block embedded in a tool-result was persistent in the assistant message history, so the imperative 'You MUST address these before continuing' got re-evaluated as fresh on every subsequent LLM step. Result: the model repeatedly thought about and re-acknowledged the same interrupt 5-10+ times per chat (verified in production DB traces — e.g. tab 4c5727aa had 11 thinking chunks quoting a single interrupt verbatim). agent.ts (toModelMessages): strip [USER INTERRUPT] from every tool- result except the one in the freshest tool-batch (last chunk of the last assistant message, which itself must be the last message). The strip is a serialization-time transform only — this.messages, the DB row, and the UI display all keep the full text. The LLM sees the imperative exactly once: the step immediately after injection. agent.ts (tool execution loop): batch queued messages across the group's tool calls and inject them only into the LAST executable tool's result. Previously the first tool to dequeue won; now the interrupt lands in a single deterministic spot regardless of timing. Tool-level handlers (run-shell/youtube/retrieve) are untouched — they still embed their own interrupt text when they background work. Also fix pre-existing tabs.test.ts: it referenced a getDescendantIds function that didn't exist (added: BFS, leaf-first, cycle-safe, skips archived) and imported bun:sqlite directly which vite couldn't resolve (rewrote with a minimal FakeDatabase + vi.mock pattern matching the rest of the suite).
-rw-r--r--packages/core/src/agent/agent.ts141
-rw-r--r--packages/core/src/db/tabs.ts37
-rw-r--r--packages/core/tests/db/tabs.test.ts275
3 files changed, 320 insertions, 133 deletions
diff --git a/packages/core/src/agent/agent.ts b/packages/core/src/agent/agent.ts
index 7def41c..bb4ee7d 100644
--- a/packages/core/src/agent/agent.ts
+++ b/packages/core/src/agent/agent.ts
@@ -46,10 +46,65 @@ import type {
* LLM, so this case only arises mid-step (where the message hasn't been
* round-tripped to the LLM yet) and is benign.
*/
+/**
+ * Marker used to identify the start of a `[USER INTERRUPT]` block embedded
+ * in a tool result. Both the agent-level injection
+ * (`packages/core/src/agent/agent.ts`) and the tool-level injections in
+ * `run-shell`, `youtube-transcribe`, and `retrieve` use the same separator
+ * (`\n\n[USER INTERRUPT]`) before the boilerplate, so a single substring
+ * search suffices for stripping.
+ */
+const USER_INTERRUPT_MARKER = "\n\n[USER INTERRUPT]";
+
+/**
+ * Remove the `[USER INTERRUPT]` block (and everything after it) from a tool
+ * result string. Used when a historical tool-result is being re-serialized
+ * for the LLM and the model has already had a chance to address that
+ * interrupt — leaving the imperative "You MUST address these" text in
+ * place causes the model to re-acknowledge the same interrupt on every
+ * subsequent step.
+ *
+ * The interrupt block is always appended to the end of the tool result, so
+ * we strip from the marker to end-of-string.
+ */
+function stripUserInterruptBlock(result: string): string {
+ const idx = result.indexOf(USER_INTERRUPT_MARKER);
+ if (idx === -1) return result;
+ return result.slice(0, idx);
+}
+
function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): ModelMessage[] {
const result: ModelMessage[] = [];
- for (const msg of messages) {
- if (msg.role === "system") continue;
+
+ // A `[USER INTERRUPT]` block in a tool-result is "fresh" — i.e., the
+ // model has not yet seen and responded to it — only when ALL of these
+ // hold:
+ // 1. The tool-batch is in the very last message of the history.
+ // 2. That message is an assistant message (a follow-up user message
+ // means the user moved on; the interrupt was addressed).
+ // 3. The tool-batch is the LAST chunk in that message (any later
+ // text/thinking/tool-batch in the same message represents the
+ // model's response to the tool results).
+ //
+ // All other interrupts get stripped from history because the imperative
+ // "You MUST address these" otherwise gets re-evaluated as a fresh
+ // instruction on every subsequent LLM step.
+ let freshestToolBatchMsgIdx = -1;
+ let freshestToolBatchChunkIdx = -1;
+ const lastMsgIdx = messages.length - 1;
+ const lastMsg = messages[lastMsgIdx];
+ if (lastMsg && lastMsg.role === "assistant" && lastMsg.chunks.length > 0) {
+ const lastChunkIdx = lastMsg.chunks.length - 1;
+ const lastChunk = lastMsg.chunks[lastChunkIdx];
+ if (lastChunk && lastChunk.type === "tool-batch") {
+ freshestToolBatchMsgIdx = lastMsgIdx;
+ freshestToolBatchChunkIdx = lastChunkIdx;
+ }
+ }
+
+ for (let msgIdx = 0; msgIdx < messages.length; msgIdx++) {
+ const msg = messages[msgIdx];
+ if (!msg || msg.role === "system") continue;
if (msg.role === "user") {
// User messages in our model can in theory contain non-text chunks,
@@ -75,7 +130,9 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
result: string;
}> = [];
- for (const chunk of msg.chunks) {
+ for (let chunkIdx = 0; chunkIdx < msg.chunks.length; chunkIdx++) {
+ const chunk = msg.chunks[chunkIdx];
+ if (!chunk) continue;
switch (chunk.type) {
case "text":
parts.push({ type: "text", text: chunk.text });
@@ -92,7 +149,15 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
: {}),
});
break;
- case "tool-batch":
+ case "tool-batch": {
+ // Strip stale `[USER INTERRUPT]` blocks from every
+ // tool-batch except the freshest one (most recent
+ // tool-batch in the most recent assistant message).
+ // Without this, the imperative "You MUST address these"
+ // text persists in history and the model re-acknowledges
+ // the same interrupt verbatim on every subsequent step.
+ const isFreshestToolBatch =
+ msgIdx === freshestToolBatchMsgIdx && chunkIdx === freshestToolBatchChunkIdx;
for (const entry of chunk.calls) {
const toolName = useToolPrefix ? prefixToolName(entry.name) : entry.name;
// v6: `input` replaces v4's `args`
@@ -103,14 +168,18 @@ function toModelMessages(messages: ChatMessage[], useToolPrefix?: boolean): Mode
input: entry.arguments,
});
if (entry.result !== undefined) {
+ const resultText = isFreshestToolBatch
+ ? entry.result
+ : stripUserInterruptBlock(entry.result);
trailingToolResults.push({
toolCallId: entry.id,
toolName,
- result: entry.result,
+ result: resultText,
});
}
}
break;
+ }
case "error":
case "system":
// Strip — not sent back to the LLM.
@@ -902,8 +971,29 @@ export class Agent {
}
}
- for (const tc of stepToolCalls) {
- if (alreadyResolved.has(tc.id)) continue;
+ // Identify the index of the last tool in this batch that will
+ // actually execute. Queued user messages are buffered across
+ // this batch and injected into ONLY that tool's result, so the
+ // interrupt appears exactly once per step rather than fragmented
+ // across whichever tool happened to dequeue first. Tool-level
+ // interrupt handlers in run-shell/youtube-transcribe/retrieve
+ // still embed their own interrupt text in their return values —
+ // that path is independent and remains correct.
+ let lastExecutableIdx = -1;
+ for (let i = 0; i < stepToolCalls.length; i++) {
+ const tcAt = stepToolCalls[i];
+ if (tcAt && !alreadyResolved.has(tcAt.id)) lastExecutableIdx = i;
+ }
+
+ // Accumulator for messages dequeued during this batch. Drained
+ // only at `lastExecutableIdx`. Destructive dequeue at the queue
+ // level prevents the same message from appearing in subsequent
+ // batches.
+ const batchPendingInjection: { id: string; message: string; timestamp: number }[] = [];
+
+ for (let tcIdx = 0; tcIdx < stepToolCalls.length; tcIdx++) {
+ const tc = stepToolCalls[tcIdx];
+ if (!tc || alreadyResolved.has(tc.id)) continue;
const shellOutputQueue: Array<{ data: string; stream: "stdout" | "stderr" }> = [];
@@ -952,23 +1042,46 @@ export class Agent {
}
}
- // Check for queued user messages and append them to the tool result
- let finalToolResult = toolResult;
+ // Harvest any queued user messages but DEFER injection until
+ // the last tool of the batch. This collapses multiple
+ // queued messages into a single interrupt block on a single
+ // tool-result instead of fragmenting across the batch.
if (this.queueCallbacks) {
const queuedMsgs = this.queueCallbacks.dequeueMessages();
if (queuedMsgs.length > 0) {
- const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n");
- finalToolResult = {
- ...toolResult,
- result: `${toolResult.result}\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`,
- };
+ batchPendingInjection.push(...queuedMsgs);
}
}
+ let finalToolResult = toolResult;
+ if (tcIdx === lastExecutableIdx && batchPendingInjection.length > 0) {
+ const userMessages = batchPendingInjection.map((m) => m.message).join("\n---\n");
+ finalToolResult = {
+ ...toolResult,
+ result: `${toolResult.result}\n\n[USER INTERRUPT]\nThe user has sent you message(s) while you were working. You MUST address these before continuing with your current task:\n\n${userMessages}`,
+ };
+ batchPendingInjection.length = 0;
+ }
+
const trEvent: AgentEvent = { type: "tool-result", toolResult: finalToolResult };
appendEventToChunks(chunks, trEvent);
yield trEvent;
}
+
+ // Safety net: if `lastExecutableIdx` was never reached (e.g.,
+ // no tools executed because all were already resolved) but
+ // messages were still dequeued, surface them as a user message
+ // so they aren't dropped. In practice this is rare — it only
+ // happens when the entire batch is unavailable-tool synthesized
+ // errors with a message arriving in that narrow window.
+ if (batchPendingInjection.length > 0) {
+ const userMessages = batchPendingInjection.map((m) => m.message).join("\n---\n");
+ this.messages.push({
+ role: "user",
+ chunks: [{ type: "text", text: userMessages }],
+ });
+ batchPendingInjection.length = 0;
+ }
}
// Build the final assistant message from the accumulated chunks.
diff --git a/packages/core/src/db/tabs.ts b/packages/core/src/db/tabs.ts
index c0ce809..928f434 100644
--- a/packages/core/src/db/tabs.ts
+++ b/packages/core/src/db/tabs.ts
@@ -122,3 +122,40 @@ export function archiveTab(id: string): void {
$now: Date.now(),
});
}
+
+/**
+ * Return the IDs of `rootId` plus every OPEN descendant tab, in leaf-first
+ * order (children before their parent). Archived descendants
+ * (`is_open = 0`) and their sub-trees are skipped — closing a parent
+ * shouldn't drag archived branches back into view.
+ *
+ * The starting `rootId` is always included in the result, even if no row
+ * with that id exists in the `tabs` table (graceful handling for stale
+ * references).
+ *
+ * Order matters for the cascade-close path: callers archive descendants
+ * leaf-first so foreign-key cleanup (messages, etc.) doesn't fail on
+ * partially-deleted parents.
+ *
+ * Cycle-safe: a `visited` set guards against accidental `parent_tab_id`
+ * loops that would otherwise spin forever.
+ */
+export function getDescendantIds(rootId: string): string[] {
+ const db = getDatabase();
+ const visited = new Set<string>();
+ const order: string[] = [];
+ const queue: string[] = [rootId];
+ while (queue.length > 0) {
+ const id = queue.shift() as string;
+ if (visited.has(id)) continue;
+ visited.add(id);
+ order.push(id);
+ const children = db
+ .query("SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1")
+ .all({ $id: id }) as Array<{ id: string }>;
+ for (const child of children) {
+ if (!visited.has(child.id)) queue.push(child.id);
+ }
+ }
+ return order.reverse();
+}
diff --git a/packages/core/tests/db/tabs.test.ts b/packages/core/tests/db/tabs.test.ts
index e8de3ce..e1c9bf8 100644
--- a/packages/core/tests/db/tabs.test.ts
+++ b/packages/core/tests/db/tabs.test.ts
@@ -1,49 +1,147 @@
-import { Database } from "bun:sqlite";
-import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
-
-/** In-memory database instance assigned in beforeAll. */
-let memDb: Database;
-
-// Mock getDatabase to return the in-memory database. The factory
-// captures memDb by reference — it won't be dereferenced until a test
-// calls getDescendantIds (or another exported function), by which
-// point beforeAll will have initialised the variable.
+import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+
+/**
+ * Internal row shape — matches the production `tabs` table columns.
+ * Kept loose (`Record`) on the `query()` boundary to mirror bun:sqlite's
+ * dynamic return type.
+ */
+interface TabRow {
+ id: string;
+ title: string;
+ key_id: string | null;
+ model_id: string | null;
+ parent_tab_id: string | null;
+ status: string;
+ is_open: number;
+ position: number;
+ created_at: number;
+ updated_at: number;
+}
+
+/**
+ * In-memory fake of `bun:sqlite`'s Database that implements only the
+ * queries actually issued by `tabs.ts`. This sidesteps two problems
+ * the original test had:
+ * 1. Vite's resolver can't load `bun:sqlite` (it's a Bun-native
+ * module with no on-disk file).
+ * 2. Even under `bun --bun vitest`, `vi.mock` doesn't intercept
+ * module imports because Bun's loader bypasses Vite's transforms.
+ *
+ * By implementing the exact query strings as fixed branches we avoid
+ * writing an SQL parser; if `tabs.ts` ever changes a query string,
+ * tests will fail loudly with "Unsupported query" instead of
+ * silently returning wrong data.
+ */
+class FakeDatabase {
+ rows: TabRow[] = [];
+
+ /** Match production's `db.query(sql).get|all|run(params)` shape. */
+ query(sql: string): {
+ all: (params?: Record<string, unknown>) => unknown[];
+ get: (params?: Record<string, unknown>) => unknown;
+ run: (params?: Record<string, unknown>) => void;
+ } {
+ return {
+ all: (params) => this.execSelect(sql, params),
+ get: (params) => this.execSelect(sql, params)[0] ?? null,
+ run: (params) => {
+ this.execMutation(sql, params);
+ },
+ };
+ }
+
+ private execSelect(sql: string, params?: Record<string, unknown>): unknown[] {
+ const norm = sql.replace(/\s+/g, " ").trim();
+
+ // getDescendantIds: children-of query
+ if (norm === "SELECT id FROM tabs WHERE parent_tab_id = $id AND is_open = 1") {
+ return this.rows
+ .filter((r) => r.parent_tab_id === params?.$id && r.is_open === 1)
+ .map((r) => ({ id: r.id }));
+ }
+
+ // getTab: single-row lookup
+ if (norm === "SELECT * FROM tabs WHERE id = $id") {
+ const row = this.rows.find((r) => r.id === params?.$id);
+ return row ? [row] : [];
+ }
+
+ // createTab: next-position lookup
+ if (norm === "SELECT COALESCE(MAX(position), -1) as max_pos FROM tabs WHERE is_open = 1") {
+ const positions = this.rows.filter((r) => r.is_open === 1).map((r) => r.position);
+ const maxPos = positions.length > 0 ? Math.max(...positions) : -1;
+ return [{ max_pos: maxPos }];
+ }
+
+ throw new Error(`FakeDatabase: unsupported SELECT: ${norm}`);
+ }
+
+ private execMutation(sql: string, params?: Record<string, unknown>): void {
+ const norm = sql.replace(/\s+/g, " ").trim();
+
+ // createTab: full-row insert (every column named, $-bound params)
+ if (
+ norm ===
+ "INSERT INTO tabs (id, title, key_id, model_id, parent_tab_id, status, is_open, position, created_at, updated_at) VALUES ($id, $title, $keyId, $modelId, $parentTabId, 'idle', 1, $position, $now, $now)"
+ ) {
+ const id = params?.$id as string;
+ if (this.rows.some((r) => r.id === id)) {
+ throw new Error(`UNIQUE constraint failed: tabs.id (${id})`);
+ }
+ this.rows.push({
+ id,
+ title: (params?.$title as string) ?? "",
+ key_id: (params?.$keyId as string | null) ?? null,
+ model_id: (params?.$modelId as string | null) ?? null,
+ parent_tab_id: (params?.$parentTabId as string | null) ?? null,
+ status: "idle",
+ is_open: 1,
+ position: (params?.$position as number) ?? 0,
+ created_at: (params?.$now as number) ?? 0,
+ updated_at: (params?.$now as number) ?? 0,
+ });
+ return;
+ }
+
+ // archiveTab: flip is_open to 0
+ if (norm === "UPDATE tabs SET is_open = 0, updated_at = $now WHERE id = $id") {
+ const row = this.rows.find((r) => r.id === params?.$id);
+ if (row) {
+ row.is_open = 0;
+ row.updated_at = (params?.$now as number) ?? Date.now();
+ }
+ return;
+ }
+
+ throw new Error(`FakeDatabase: unsupported mutation: ${norm}`);
+ }
+}
+
+/**
+ * Shared instance referenced by both the test setup and the
+ * `vi.mock` factory below. Declared with `let` (not `const`) so the
+ * factory's closure picks up the value assigned in `beforeAll`.
+ */
+let fakeDb: FakeDatabase;
+
+// Mock the db module before importing `tabs.ts` so that `getDatabase()`
+// returns our in-memory fake instead of trying to open a real SQLite
+// file. Mirrors the same pattern used by `tests/agent/agent.test.ts`.
vi.mock("../../src/db/index.js", () => ({
- getDatabase: vi.fn(() => memDb),
+ getDatabase: vi.fn(() => fakeDb),
}));
-// Dynamic import AFTER the mock is registered (hoisted) so the
-// module-under-test sees the mocked getDatabase.
-const {
- getDescendantIds,
- createTab,
- archiveTab,
- getTab,
-} = await import("../../src/db/tabs.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");
beforeAll(() => {
- memDb = new Database(":memory:");
- memDb.run(`CREATE TABLE tabs (
- id TEXT PRIMARY KEY,
- title TEXT NOT NULL,
- key_id TEXT,
- model_id TEXT,
- parent_tab_id TEXT,
- status TEXT NOT NULL DEFAULT 'idle',
- is_open INTEGER NOT NULL DEFAULT 1,
- position INTEGER NOT NULL DEFAULT 0,
- created_at INTEGER NOT NULL,
- updated_at INTEGER NOT NULL
- )`);
+ fakeDb = new FakeDatabase();
});
-afterAll(() => {
- memDb.close();
-});
-
-/** Wipe the tabs table between tests so every test starts clean. */
beforeEach(() => {
- memDb.run("DELETE FROM tabs");
+ fakeDb.rows = [];
});
// ---------------------------------------------------------------------------
@@ -51,34 +149,16 @@ beforeEach(() => {
// ---------------------------------------------------------------------------
describe("getDescendantIds", () => {
it("returns only the id when the tab has no children", () => {
- const now = Date.now();
- memDb.run(
- `INSERT INTO tabs (id, title, status, is_open, position, created_at, updated_at)
- VALUES ('root', 'Root', 'idle', 1, 0, $now, $now)`,
- { $now: now },
- );
+ createTab("root", "Root");
const ids = getDescendantIds("root");
expect(ids).toEqual(["root"]);
});
it("returns leaf-first order for a linear chain (root → child → grandchild)", () => {
- const now = Date.now();
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('root', 'Root', NULL, 'idle', 1, 0, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('child', 'Child', 'root', 'idle', 1, 1, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('grandchild', 'Grandchild', 'child', 'idle', 1, 2, $now, $now)`,
- { $now: now },
- );
+ createTab("root", "Root");
+ createTab("child", "Child", { parentTabId: "root" });
+ createTab("grandchild", "Grandchild", { parentTabId: "child" });
const ids = getDescendantIds("root");
// Leaves first: grandchild, child, root
@@ -86,32 +166,11 @@ describe("getDescendantIds", () => {
});
it("returns leaf-first for a branching tree", () => {
- const now = Date.now();
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('a', 'A', NULL, 'idle', 1, 0, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('b1', 'B1', 'a', 'idle', 1, 1, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('b2', 'B2', 'a', 'idle', 1, 2, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('c1', 'C1', 'b1', 'idle', 1, 3, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('c2', 'C2', 'b1', 'idle', 1, 4, $now, $now)`,
- { $now: now },
- );
+ createTab("a", "A");
+ createTab("b1", "B1", { parentTabId: "a" });
+ createTab("b2", "B2", { parentTabId: "a" });
+ createTab("c1", "C1", { parentTabId: "b1" });
+ createTab("c2", "C2", { parentTabId: "b1" });
const ids = getDescendantIds("a");
// BFS: a, b1, b2, c1, c2 → reverse: c2, c1, b2, b1, a
@@ -119,30 +178,14 @@ describe("getDescendantIds", () => {
});
it("skips archived descendants (is_open = 0)", () => {
- const now = Date.now();
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('root', 'Root', NULL, 'idle', 1, 0, $now, $now)`,
- { $now: now },
- );
+ createTab("root", "Root");
// Open child of root — should appear
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('open-child', 'Open', 'root', 'idle', 1, 1, $now, $now)`,
- { $now: now },
- );
+ createTab("open-child", "Open", { parentTabId: "root" });
// Archived child — should be skipped together with its descendants
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('archived-child', 'Archived', 'root', 'idle', 0, 2, $now, $now)`,
- { $now: now },
- );
- // Child of archived — data drift, should NOT appear
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('orphan', 'Orphan', 'archived-child', 'idle', 1, 3, $now, $now)`,
- { $now: now },
- );
+ createTab("archived-child", "Archived", { parentTabId: "root" });
+ archiveTab("archived-child");
+ // Child of archived — data drift, should NOT appear (parent is archived)
+ createTab("orphan", "Orphan", { parentTabId: "archived-child" });
const ids = getDescendantIds("root");
expect(ids).toEqual(["open-child", "root"]);
@@ -156,17 +199,11 @@ describe("getDescendantIds", () => {
});
it("defends against accidental parent_tab_id cycles", () => {
- const now = Date.now();
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('x', 'X', 'y', 'idle', 1, 0, $now, $now)`,
- { $now: now },
- );
- memDb.run(
- `INSERT INTO tabs (id, title, parent_tab_id, status, is_open, position, created_at, updated_at)
- VALUES ('y', 'Y', 'x', 'idle', 1, 1, $now, $now)`,
- { $now: now },
- );
+ // Insert x first with a forward reference to y (y doesn't exist
+ // yet — the schema has no foreign key enforcement). Then insert
+ // y with parent_tab_id = x. Result: x.parent = y, y.parent = x.
+ createTab("x", "X", { parentTabId: "y" });
+ createTab("y", "Y", { parentTabId: "x" });
// Must terminate — no infinite loop
const ids = getDescendantIds("x");