summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/db
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 /packages/core/src/db
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).
Diffstat (limited to 'packages/core/src/db')
-rw-r--r--packages/core/src/db/tabs.ts37
1 files changed, 37 insertions, 0 deletions
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();
+}