diff options
| author | Adam Malczewski <[email protected]> | 2026-05-28 07:15:32 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-28 07:15:32 +0900 |
| commit | 2f14260bb0f1a51d51e516feda285b68f793ae1b (patch) | |
| tree | f366e3576c65b0e11d81fca0e84714771ebf83ce /packages/api/src | |
| parent | 8b17d929e70a43749fd962554214bf8ba3e9380f (diff) | |
| download | dispatch-2f14260bb0f1a51d51e516feda285b68f793ae1b.tar.gz dispatch-2f14260bb0f1a51d51e516feda285b68f793ae1b.zip | |
fix(api): pre-populate Agent.messages from DB on construction so model switches preserve chat history
Before this change, swapping the model mid-conversation via the sidebar
slider lost all prior turns: the new model saw only the current user
message and treated the conversation as brand-new.
Root cause: `getOrCreateAgentForTab` invalidates the cached Agent
(`tabAgent.agent = null`) whenever the effective keyId, modelId,
permission key, or working directory differs from the cached values.
The replacement Agent was then constructed with `messages: []` and
the post-construction step that loads prior turns from the SQLite
`messages` table simply did not exist. `processMessage` had already
appended the current turn's user message to the DB (line 960) before
calling `getOrCreateAgentForTab` (line 1015), so the DB held the full
context — it was just never read.
Fix: after every `new Agent(...)` in `getOrCreateAgentForTab`, call
`getMessagesForTab(tabId)`, walk backwards to the most recent user-role
row, and assign all strictly-prior rows to `tabAgent.agent.messages`.
The walk-backwards strategy correctly handles two boundary cases:
1. Simple model switch — last DB row is the current user message;
drop it (`Agent.run()` will push it again at line 546).
2. Agent-mode auto-fallback retry — last DB row may be a partial
assistant response flushed by the previous failed attempt; we
drop both that and the current user message in one pass.
System-role rows (config-reload notices, etc.) are preserved verbatim;
`toModelMessages` already strips them before the wire payload, so this
is safe.
The fix covers every Agent-reconstruction trigger, not just the model
slider:
- Sidebar model/key change (the reported case)
- Permission setting change
- Working-directory change (`processMessage` line 951)
- dispatch.toml config-watcher reload (lines 236–237)
- Skills directory watcher reload (lines 249–250)
- `stopTab` after user cancellation (line 775)
If `getMessagesForTab` throws (e.g. DB locked, schema mismatch), we
swallow the error and leave `messages: []` — matching pre-fix
behaviour for that case so this commit never regresses.
Tests (+6 in `packages/api/tests/agent-manager.test.ts`, total 26):
- pre-populates Agent.messages from DB history
- leaves messages empty when DB has only the current turn (first msg)
- excludes a partial assistant trail from a prior fallback attempt
- preserves system-role rows in pre-populated history
- survives a getMessagesForTab failure without crashing
- reloads history on every Agent reconstruction (simulated slider
switch from Opus to DeepSeek across two processMessage calls)
The test rig was extended with:
- `fakeMessagesByTab` map + `setFakeMessages` helper letting tests
inject arbitrary DB rows for the mocked `getMessagesForTab`.
- `constructedAgents` array captured at `run()` entry (not at
construction) so each test sees the post-pre-populate snapshot;
the production code reassigns `agent.messages` after `new Agent()`
returns, so capturing at construction yielded a stale empty array.
- Pluggable `runImpl` hook for tests that want a custom event stream
(not yet exercised; staged for the next round of agent-mode
fallback tests).
Totals: 229 tests across 3 packages all green; typecheck clean on
core + api + frontend; biome clean across 124 files.
Diffstat (limited to 'packages/api/src')
| -rw-r--r-- | packages/api/src/agent-manager.ts | 49 |
1 files changed, 49 insertions, 0 deletions
diff --git a/packages/api/src/agent-manager.ts b/packages/api/src/agent-manager.ts index 6e03adb..efdd732 100644 --- a/packages/api/src/agent-manager.ts +++ b/packages/api/src/agent-manager.ts @@ -654,6 +654,55 @@ export class AgentManager { waitForQueuedMessage: () => this.waitForQueuedMessage(tabId), }, ); + + // Pre-populate the Agent's in-memory message history from the DB + // so prior turns survive Agent recreation. The Agent is + // constructed fresh here in three scenarios that ALL discard + // the previous in-memory `messages` array: + // 1. First call for this tab (no prior Agent existed) + // 2. Model/key/permission/working-directory change — the + // invalidation gate above set `tabAgent.agent = null`. + // This is the model-switcher-slider case: without this + // pre-population, DeepSeek would see zero context after + // switching from Opus mid-conversation. + // 3. Config or skills reload (configWatcher / skillsWatcher + // also null out `tabAgent.agent`). + // + // Boundary semantics: `processMessage` calls `appendMessage` + // for the current turn's user message BEFORE calling this + // function, so the DB ends in `[..., u_current]`. In the + // fallback retry path (agent-mode automatic model fallback), + // the previous attempt may also have flushed a partial + // assistant response, so the DB ends in + // `[..., u_current, partial_a]`. Either way, we walk + // backwards to the most recent user-role row and load only + // strictly-prior rows: `agent.run()` will push the current + // user message itself at agent.ts:546, so including it here + // would duplicate it. + // + // `toModelMessages` already filters out `role === "system"` + // rows and strips `error` / `system` chunks, so it's safe to + // load system messages verbatim. + try { + const rows = getMessagesForTab(tabId); + let cutIdx = rows.length; + for (let i = rows.length - 1; i >= 0; i--) { + const row = rows[i]; + if (row && row.role === "user") { + cutIdx = i; + break; + } + } + if (cutIdx > 0) { + tabAgent.agent.messages = rows + .slice(0, cutIdx) + .map((r) => ({ role: r.role, chunks: r.chunks })); + } + } catch { + // DB read failed — leave `messages: []`. The agent still + // works, just without prior history (matches pre-fix + // behaviour, so this is no worse than what we had before). + } } return tabAgent.agent; } |
