detached 5a2cfb5

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) {
13161316 i.mu.Unlock()
13171317 return
13181318 }
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+
13191330 ag, p, md, err := i.cfg.BuildAgentFor(m.Provider, m.ID)
13201331 if err != nil {
13211332 i.mu.Lock()
@@ -1323,12 +1334,27 @@ func (i *Interactive) applyModelSelection(prov, model string) {
13231334 i.mu.Unlock()
13241335 return
13251336 }
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+
13261349 i.mu.Lock()
13271350 i.agent = ag
13281351 i.cfg.Provider = p
13291352 i.cfg.Model = md
13301353 i.statusOK = "switched to " + p + " / " + md
13311354 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.
13321358 i.mu.Unlock()
13331359 if i.cfg.PersistModel != nil {
13341360 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 return1317 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 return1335 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 = ag1350 i.agent = ag
1328 i.cfg.Provider = p1351 i.cfg.Provider = p
1329 i.cfg.Model = md1352 i.cfg.Model = md
1330 i.statusOK = "switched to " + p + " / " + md1353 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 {
6060 return a.cost.Total
6161 }
6262
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+
6372 // Prompt sends a user message and runs the agent loop until the model
6473 // stops or an error occurs. Events are delivered via sink in order.
6574 // 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.Total60 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 model72 // 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.

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 476

↑↓ moveenter openesc close