diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 10:33:33 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 10:33:33 +0900 |
| commit | 2eeabc95b78f6624c187e1e3892f9413266b4b9a (patch) | |
| tree | ffdcd754bafb4da036b1fd3dfb22617754067641 /packages/core/src/db | |
| parent | 1e70f2d12274da833035912206b1ac0b1ee57ef1 (diff) | |
| download | dispatch-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.ts | 37 |
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(); +} |
