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.
This commit is contained in:
patriceckhart 2026-04-19 12:47:12 +02:00
parent 3ff6d9e6b7
commit 5a2cfb525e
2 changed files with 35 additions and 0 deletions

View file

@ -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)

View file

@ -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.