From 5a2cfb525efc25330cc9f33d1e90e488393a481c Mon Sep 17 00:00:00 2001 From: patriceckhart Date: Sun, 19 Apr 2026 12:47:12 +0200 Subject: [PATCH] fix: keep transcript and cumulative cost across cross-provider /model swap applyModelSelection's cross-provider branch built a fresh agent via BuildAgentFor and assigned it to i.agent without copying anything from the old one, so the user perceived /model anthropic->openai (or vice versa) as a hard reset of the entire conversation. same-provider swaps already worked because they only mutated agent.Model in place. now the cross-provider path snapshots agent.Messages() and agent.Cost() before constructing the replacement, then SetMessages + SeedCost on the new agent so the transcript and the cumulative dollar meter both carry across. added core.Agent.SeedCost(provider.Usage) for the cost transfer. messages already round-trip cleanly because tool names are normalised to lowercase in the in-memory provider.Message representation; the oauth-only Read/Write/Edit/Bash rename happens on the wire at request build time, not in the transcript. verified end-to-end via zot rpc: turn 1 (opus): "remember teal" -> "ok" set_model: opus -> sonnet turn 2 (sonnet): "what color did i say?" -> "teal" both same-provider (opus<->sonnet) and cross-provider (anthropic<->openai) paths exercised. --- internal/agent/modes/interactive.go | 26 ++++++++++++++++++++++++++ internal/core/agent.go | 9 +++++++++ 2 files changed, 35 insertions(+) diff --git a/internal/agent/modes/interactive.go b/internal/agent/modes/interactive.go index 0d1b206..82c2fd9 100644 --- a/internal/agent/modes/interactive.go +++ b/internal/agent/modes/interactive.go @@ -1316,6 +1316,17 @@ func (i *Interactive) applyModelSelection(prov, model string) { i.mu.Unlock() return } + // Snapshot the current transcript and cumulative usage BEFORE we + // build the replacement agent so we can hand them off. Without + // this the user perceives the entire session as wiped on a + // cross-provider /model swap. + var carryMsgs []provider.Message + var carryCost provider.Usage + if i.agent != nil { + carryMsgs = i.agent.Messages() + carryCost = i.agent.Cost() + } + ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID) if err != nil { i.mu.Lock() @@ -1323,12 +1334,27 @@ func (i *Interactive) applyModelSelection(prov, model string) { i.mu.Unlock() return } + + // Replay the transcript and seed the cost on the freshly-built + // agent. Messages travel cleanly between providers because they + // use the same provider.Message shape; tool-call ids are local + // to a turn so cross-provider continuation never confuses the + // new model (it just sees the assistant's reply, no orphan + // tool_use blocks because /model swaps are gated to idle state). + if len(carryMsgs) > 0 { + ag.SetMessages(carryMsgs) + } + ag.SeedCost(carryCost) + i.mu.Lock() i.agent = ag i.cfg.Provider = p i.cfg.Model = md i.statusOK = "switched to " + p + " / " + md i.statusErr = "" + // Render cache keys are width+content based, so the new agent's + // identical messages will reuse the existing entries. Nothing + // to invalidate. i.mu.Unlock() if i.cfg.PersistModel != nil { i.cfg.PersistModel(p, md) diff --git a/internal/core/agent.go b/internal/core/agent.go index 38f23e1..6b9f750 100644 --- a/internal/core/agent.go +++ b/internal/core/agent.go @@ -60,6 +60,15 @@ func (a *Agent) Cost() provider.Usage { return a.cost.Total } +// SeedCost sets the cumulative usage as a baseline before the first +// turn runs. Used when transferring state from another agent (model +// or provider switch) so the running cost meter doesn't reset to 0. +func (a *Agent) SeedCost(u provider.Usage) { + a.mu.Lock() + defer a.mu.Unlock() + a.cost.Total = u +} + // Prompt sends a user message and runs the agent loop until the model // stops or an error occurs. Events are delivered via sink in order. // sink must not block the caller for long; buffer as needed.