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.
- commit
- 5a2cfb525efc25330cc9f33d1e90e488393a481c
- author
- patriceckhart · · 2026-04-19 10:47 UTC
- parent
- 3ff6d9e
- signature
- unsigned
- refs
- tag: v0.0.16
2 files · +35 -0
M internal/agent/modes/interactive.go +26 -0
| @@ -1316,6 +1316,17 @@ func (i *Interactive) applyModelSelection(prov, model string) { | |||
| 1316 | 1316 | i.mu.Unlock() | |
| 1317 | 1317 | return | |
| 1318 | 1318 | } | |
| 1319 | + | // Snapshot the current transcript and cumulative usage BEFORE we | |
| 1320 | + | // build the replacement agent so we can hand them off. Without | |
| 1321 | + | // this the user perceives the entire session as wiped on a | |
| 1322 | + | // cross-provider /model swap. | |
| 1323 | + | var carryMsgs []provider.Message | |
| 1324 | + | var carryCost provider.Usage | |
| 1325 | + | if i.agent != nil { | |
| 1326 | + | carryMsgs = i.agent.Messages() | |
| 1327 | + | carryCost = i.agent.Cost() | |
| 1328 | + | } | |
| 1329 | + | ||
| 1319 | 1330 | ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID) | |
| 1320 | 1331 | if err != nil { | |
| 1321 | 1332 | i.mu.Lock() | |
| @@ -1323,12 +1334,27 @@ func (i *Interactive) applyModelSelection(prov, model string) { | |||
| 1323 | 1334 | i.mu.Unlock() | |
| 1324 | 1335 | return | |
| 1325 | 1336 | } | |
| 1337 | + | ||
| 1338 | + | // Replay the transcript and seed the cost on the freshly-built | |
| 1339 | + | // agent. Messages travel cleanly between providers because they | |
| 1340 | + | // use the same provider.Message shape; tool-call ids are local | |
| 1341 | + | // to a turn so cross-provider continuation never confuses the | |
| 1342 | + | // new model (it just sees the assistant's reply, no orphan | |
| 1343 | + | // tool_use blocks because /model swaps are gated to idle state). | |
| 1344 | + | if len(carryMsgs) > 0 { | |
| 1345 | + | ag.SetMessages(carryMsgs) | |
| 1346 | + | } | |
| 1347 | + | ag.SeedCost(carryCost) | |
| 1348 | + | ||
| 1326 | 1349 | i.mu.Lock() | |
| 1327 | 1350 | i.agent = ag | |
| 1328 | 1351 | i.cfg.Provider = p | |
| 1329 | 1352 | i.cfg.Model = md | |
| 1330 | 1353 | i.statusOK = "switched to " + p + " / " + md | |
| 1331 | 1354 | i.statusErr = "" | |
| 1355 | + | // Render cache keys are width+content based, so the new agent's | |
| 1356 | + | // identical messages will reuse the existing entries. Nothing | |
| 1357 | + | // to invalidate. | |
| 1332 | 1358 | i.mu.Unlock() | |
| 1333 | 1359 | if i.cfg.PersistModel != nil { | |
| 1334 | 1360 | i.cfg.PersistModel(p, md) | |
| @@ -1316,6 +1316,17 @@ func (i *Interactive) applyModelSelection(prov, model string) { | |||||
| 1316 | i.mu.Unlock() | 1316 | i.mu.Unlock() | ||
| 1317 | return | 1317 | return | ||
| 1318 | } | 1318 | } | ||
| 1319 | + | // Snapshot the current transcript and cumulative usage BEFORE we | |||
| 1320 | + | // build the replacement agent so we can hand them off. Without | |||
| 1321 | + | // this the user perceives the entire session as wiped on a | |||
| 1322 | + | // cross-provider /model swap. | |||
| 1323 | + | var carryMsgs []provider.Message | |||
| 1324 | + | var carryCost provider.Usage | |||
| 1325 | + | if i.agent != nil { | |||
| 1326 | + | carryMsgs = i.agent.Messages() | |||
| 1327 | + | carryCost = i.agent.Cost() | |||
| 1328 | + | } | |||
| 1329 | + | ||||
| 1319 | ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID) | 1330 | ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID) | ||
| 1320 | if err != nil { | 1331 | if err != nil { | ||
| 1321 | i.mu.Lock() | 1332 | i.mu.Lock() | ||
| @@ -1323,12 +1334,27 @@ func (i *Interactive) applyModelSelection(prov, model string) { | |||||
| 1323 | i.mu.Unlock() | 1334 | i.mu.Unlock() | ||
| 1324 | return | 1335 | return | ||
| 1325 | } | 1336 | } | ||
| 1337 | + | ||||
| 1338 | + | // Replay the transcript and seed the cost on the freshly-built | |||
| 1339 | + | // agent. Messages travel cleanly between providers because they | |||
| 1340 | + | // use the same provider.Message shape; tool-call ids are local | |||
| 1341 | + | // to a turn so cross-provider continuation never confuses the | |||
| 1342 | + | // new model (it just sees the assistant's reply, no orphan | |||
| 1343 | + | // tool_use blocks because /model swaps are gated to idle state). | |||
| 1344 | + | if len(carryMsgs) > 0 { | |||
| 1345 | + | ag.SetMessages(carryMsgs) | |||
| 1346 | + | } | |||
| 1347 | + | ag.SeedCost(carryCost) | |||
| 1348 | + | ||||
| 1326 | i.mu.Lock() | 1349 | i.mu.Lock() | ||
| 1327 | i.agent = ag | 1350 | i.agent = ag | ||
| 1328 | i.cfg.Provider = p | 1351 | i.cfg.Provider = p | ||
| 1329 | i.cfg.Model = md | 1352 | i.cfg.Model = md | ||
| 1330 | i.statusOK = "switched to " + p + " / " + md | 1353 | i.statusOK = "switched to " + p + " / " + md | ||
| 1331 | i.statusErr = "" | 1354 | i.statusErr = "" | ||
| 1355 | + | // Render cache keys are width+content based, so the new agent's | |||
| 1356 | + | // identical messages will reuse the existing entries. Nothing | |||
| 1357 | + | // to invalidate. | |||
| 1332 | i.mu.Unlock() | 1358 | i.mu.Unlock() | ||
| 1333 | if i.cfg.PersistModel != nil { | 1359 | if i.cfg.PersistModel != nil { | ||
| 1334 | i.cfg.PersistModel(p, md) | 1360 | i.cfg.PersistModel(p, md) | ||
M internal/core/agent.go +9 -0
| @@ -60,6 +60,15 @@ func (a *Agent) Cost() provider.Usage { | |||
| 60 | 60 | return a.cost.Total | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | + | // SeedCost sets the cumulative usage as a baseline before the first | |
| 64 | + | // turn runs. Used when transferring state from another agent (model | |
| 65 | + | // or provider switch) so the running cost meter doesn't reset to 0. | |
| 66 | + | func (a *Agent) SeedCost(u provider.Usage) { | |
| 67 | + | a.mu.Lock() | |
| 68 | + | defer a.mu.Unlock() | |
| 69 | + | a.cost.Total = u | |
| 70 | + | } | |
| 71 | + | ||
| 63 | 72 | // Prompt sends a user message and runs the agent loop until the model | |
| 64 | 73 | // stops or an error occurs. Events are delivered via sink in order. | |
| 65 | 74 | // sink must not block the caller for long; buffer as needed. | |
| @@ -60,6 +60,15 @@ func (a *Agent) Cost() provider.Usage { | |||||
| 60 | return a.cost.Total | 60 | return a.cost.Total | ||
| 61 | } | 61 | } | ||
| 62 | 62 | ||||
| 63 | + | // SeedCost sets the cumulative usage as a baseline before the first | |||
| 64 | + | // turn runs. Used when transferring state from another agent (model | |||
| 65 | + | // or provider switch) so the running cost meter doesn't reset to 0. | |||
| 66 | + | func (a *Agent) SeedCost(u provider.Usage) { | |||
| 67 | + | a.mu.Lock() | |||
| 68 | + | defer a.mu.Unlock() | |||
| 69 | + | a.cost.Total = u | |||
| 70 | + | } | |||
| 71 | + | ||||
| 63 | // Prompt sends a user message and runs the agent loop until the model | 72 | // Prompt sends a user message and runs the agent loop until the model | ||
| 64 | // stops or an error occurs. Events are delivered via sink in order. | 73 | // stops or an error occurs. Events are delivered via sink in order. | ||
| 65 | // sink must not block the caller for long; buffer as needed. | 74 | // sink must not block the caller for long; buffer as needed. | ||