detached 3b3777e

fix(provider): isolate reasoning replay across model switches (#193)

* fix(provider): isolate reasoning replay across model switches

* fix(replay): preserve Vertex and historical branch origins
commit
3b3777e92a23789bb85ee43f37ff38f7f78099dc
author
bnema · · 2026-09-08 11:03 UTC
committer
GitHub · · 2026-09-08 11:03 UTC
parent
362913c
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
12 files · +300 -16
M docs/providers.md +3 -1
@@ -258,7 +258,9 @@ When a Responses completion carries an explicit `end_turn:false`, zut treats the
258258
259259 ### Responses reasoning replay
260260
261-Responses requests run with `store:false`, so follow-up requests replay reasoning explicitly. Only reasoning blocks that carry a replayable encrypted payload are sent; reasoning metadata without that payload is omitted from outgoing requests without editing session history. Opaque reasoning payloads are not guaranteed portable across providers or models.
261+You can switch models or providers within the same session. Assistant text and tool exchanges remain available; encrypted reasoning and thought signatures from a different provider or model are omitted from outgoing requests without editing session history. Switching back allows the matching model to replay its own metadata.
262+
263+Responses requests run with `store:false`, so only reasoning blocks with an encrypted payload and a valid Responses item ID (when present) are replayed. Session loading recovers message provenance from the provider/model metadata recorded alongside turns. Older compacted messages without provenance retain best-effort replay; malformed reasoning IDs are omitted.
262264
263265 ## Local llama.cpp router
264266
@@ -258,7 +258,9 @@ When a Responses completion carries an explicit `end_turn:false`, zut treats the
258 258
259 ### Responses reasoning replay259 ### Responses reasoning replay
260 260
261-Responses requests run with `store:false`, so follow-up requests replay reasoning explicitly. Only reasoning blocks that carry a replayable encrypted payload are sent; reasoning metadata without that payload is omitted from outgoing requests without editing session history. Opaque reasoning payloads are not guaranteed portable across providers or models.261+You can switch models or providers within the same session. Assistant text and tool exchanges remain available; encrypted reasoning and thought signatures from a different provider or model are omitted from outgoing requests without editing session history. Switching back allows the matching model to replay its own metadata.
262+
263+Responses requests run with `store:false`, so only reasoning blocks with an encrypted payload and a valid Responses item ID (when present) are replayed. Session loading recovers message provenance from the provider/model metadata recorded alongside turns. Older compacted messages without provenance retain best-effort replay; malformed reasoning IDs are omitted.
262 264
263 ## Local llama.cpp router265 ## Local llama.cpp router
264 266
M packages/agent/modes/session_tree_dialog.go +11 -1
@@ -919,7 +919,17 @@ func compactionTailIndices(segments []core.SessionHistorySegment, segmentIdx int
919919 maxTail = len(previous)
920920 }
921921 for tailLen := maxTail; tailLen > 0; tailLen-- {
922- if !reflect.DeepEqual(previous[len(previous)-tailLen:], current[1:1+tailLen]) {
922+ matched := true
923+ for idx, prior := range previous[len(previous)-tailLen:] {
924+ // Historical rows can recover provenance that an older compaction
925+ // tail did not persist. Compare with only missing origin filled.
926+ next := provider.WithMessageOrigin(current[1+idx], prior.Meta["provider"], prior.Meta["model"])
927+ if !reflect.DeepEqual(prior, next) {
928+ matched = false
929+ break
930+ }
931+ }
932+ if !matched {
923933 continue
924934 }
925935 skip := make(map[int]bool, tailLen)
@@ -919,7 +919,17 @@ func compactionTailIndices(segments []core.SessionHistorySegment, segmentIdx int
919 maxTail = len(previous)919 maxTail = len(previous)
920 }920 }
921 for tailLen := maxTail; tailLen > 0; tailLen-- {921 for tailLen := maxTail; tailLen > 0; tailLen-- {
922- if !reflect.DeepEqual(previous[len(previous)-tailLen:], current[1:1+tailLen]) {922+ matched := true
923+ for idx, prior := range previous[len(previous)-tailLen:] {
924+ // Historical rows can recover provenance that an older compaction
925+ // tail did not persist. Compare with only missing origin filled.
926+ next := provider.WithMessageOrigin(current[1+idx], prior.Meta["provider"], prior.Meta["model"])
927+ if !reflect.DeepEqual(prior, next) {
928+ matched = false
929+ break
930+ }
931+ }
932+ if !matched {
923 continue933 continue
924 }934 }
925 skip := make(map[int]bool, tailLen)935 skip := make(map[int]bool, tailLen)
M packages/core/agent.go +1 -1
@@ -1155,7 +1155,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
11551155 case provider.EventDone:
11561156 stop = e.Stop
11571157 finalErr = e.Err
1158- finalMsg = e.Message
1158+ finalMsg = provider.WithMessageOrigin(e.Message, a.Client.Name(), req.Model)
11591159 return true
11601160 }
11611161 return false
@@ -1155,7 +1155,7 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
1155 case provider.EventDone:1155 case provider.EventDone:
1156 stop = e.Stop1156 stop = e.Stop
1157 finalErr = e.Err1157 finalErr = e.Err
1158- finalMsg = e.Message1158+ finalMsg = provider.WithMessageOrigin(e.Message, a.Client.Name(), req.Model)
1159 return true1159 return true
1160 }1160 }
1161 return false1161 return false
M packages/core/session.go +6 -2
@@ -425,7 +425,10 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
425425 snapshot.Title = row.meta.Title
426426 }
427427 case "message":
428- snapshot.Messages = append(snapshot.Messages, *row.message)
428+ // Metadata rows record the route active when each turn was written,
429+ // not just the route selected at the end of the session.
430+ msg := provider.WithMessageOrigin(*row.message, snapshot.Meta.Provider, snapshot.Meta.Model)
431+ snapshot.Messages = append(snapshot.Messages, msg)
429432 case "compaction":
430433 snapshot.Messages = row.messages
431434 generation++
@@ -549,7 +552,8 @@ func readSessionHistory(ctx context.Context, path string) (SessionHistory, error
549552 case "meta":
550553 history.Meta = *row.meta
551554 case "message":
552- current.messages = append(current.messages, *row.message)
555+ msg := provider.WithMessageOrigin(*row.message, history.Meta.Provider, history.Meta.Model)
556+ current.messages = append(current.messages, msg)
553557 case "compaction":
554558 appendCurrent()
555559 current = rawSegment{compacted: true, messages: row.messages}
@@ -425,7 +425,10 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err
425 snapshot.Title = row.meta.Title425 snapshot.Title = row.meta.Title
426 }426 }
427 case "message":427 case "message":
428- snapshot.Messages = append(snapshot.Messages, *row.message)428+ // Metadata rows record the route active when each turn was written,
429+ // not just the route selected at the end of the session.
430+ msg := provider.WithMessageOrigin(*row.message, snapshot.Meta.Provider, snapshot.Meta.Model)
431+ snapshot.Messages = append(snapshot.Messages, msg)
429 case "compaction":432 case "compaction":
430 snapshot.Messages = row.messages433 snapshot.Messages = row.messages
431 generation++434 generation++
@@ -549,7 +552,8 @@ func readSessionHistory(ctx context.Context, path string) (SessionHistory, error
549 case "meta":552 case "meta":
550 history.Meta = *row.meta553 history.Meta = *row.meta
551 case "message":554 case "message":
552- current.messages = append(current.messages, *row.message)555+ msg := provider.WithMessageOrigin(*row.message, history.Meta.Provider, history.Meta.Model)
556+ current.messages = append(current.messages, msg)
553 case "compaction":557 case "compaction":
554 appendCurrent()558 appendCurrent()
555 current = rawSegment{compacted: true, messages: row.messages}559 current = rawSegment{compacted: true, messages: row.messages}
A packages/core/session_model_replay_test.go +88 -0
@@ -0,0 +1,88 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestSessionMessageOriginsAcrossSwitch(t *testing.T) {
11+ root, cwd := t.TempDir(), t.TempDir()
12+ session, err := NewSession(root, cwd, "opencode", "model-a", "test")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ defer session.Close()
17+ msg := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ReasoningBlock{ID: "rs_a:rs_b", Encrypted: "opaque"}, provider.TextBlock{Text: "answer"}}}
18+ if err := session.AppendMessage(msg); err != nil {
19+ t.Fatal(err)
20+ }
21+ if err := session.UpdateModel("openai-codex", "model-b"); err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := session.AppendMessage(msg); err != nil {
25+ t.Fatal(err)
26+ }
27+ snapshot, err := ReadSessionSnapshot(session.Path)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ for i, want := range []string{"opencode", "openai-codex"} {
32+ if got := snapshot.Messages[i].Meta["provider"]; got != want {
33+ t.Fatalf("message %d origin = %q, want %q", i, got, want)
34+ }
35+ }
36+ if err := session.AppendCompaction(snapshot.Messages); err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := session.UpdateModel("google", "model-c"); err != nil {
40+ t.Fatal(err)
41+ }
42+ snapshot, err = ReadSessionSnapshot(session.Path)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if snapshot.Messages[0].Meta["provider"] != "opencode" || snapshot.Messages[1].Meta["model"] != "model-b" {
47+ t.Fatalf("compaction lost origins: %+v", snapshot.Messages)
48+ }
49+ history, err := ReadSessionHistory(session.Path)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ branchPath, err := BranchSessionHiddenFromHistory(session.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ branch, err := ReadSessionSnapshot(branchPath)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ if len(branch.Messages) != 2 || branch.Messages[0].Meta["provider"] != "opencode" || branch.Messages[0].Meta["model"] != "model-a" || branch.Messages[1].Meta["provider"] != "openai-codex" || branch.Messages[1].Meta["model"] != "model-b" {
62+ t.Fatalf("historical branch lost origins: %+v", branch.Messages)
63+ }
64+}
65+
66+func TestAgentRecordsMessageOriginAcrossModelSwitch(t *testing.T) {
67+ client := &identityRecordingClient{}
68+ agent := NewAgent(client, "model-a", "system", Registry{})
69+ if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
70+ t.Fatal(err)
71+ }
72+ agent.Model = "model-b"
73+ if err := agent.Prompt(context.Background(), "second", nil, nil); err != nil {
74+ t.Fatal(err)
75+ }
76+ var models []string
77+ for _, msg := range agent.Messages() {
78+ if msg.Role == provider.RoleAssistant {
79+ if msg.Meta["provider"] != client.Name() {
80+ t.Fatal("missing provider origin")
81+ }
82+ models = append(models, msg.Meta["model"])
83+ }
84+ }
85+ if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" {
86+ t.Fatalf("origins = %v", models)
87+ }
88+}
@@ -0,0 +1,88 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestSessionMessageOriginsAcrossSwitch(t *testing.T) {
11+ root, cwd := t.TempDir(), t.TempDir()
12+ session, err := NewSession(root, cwd, "opencode", "model-a", "test")
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ defer session.Close()
17+ msg := provider.Message{Role: provider.RoleAssistant, Content: []provider.Content{provider.ReasoningBlock{ID: "rs_a:rs_b", Encrypted: "opaque"}, provider.TextBlock{Text: "answer"}}}
18+ if err := session.AppendMessage(msg); err != nil {
19+ t.Fatal(err)
20+ }
21+ if err := session.UpdateModel("openai-codex", "model-b"); err != nil {
22+ t.Fatal(err)
23+ }
24+ if err := session.AppendMessage(msg); err != nil {
25+ t.Fatal(err)
26+ }
27+ snapshot, err := ReadSessionSnapshot(session.Path)
28+ if err != nil {
29+ t.Fatal(err)
30+ }
31+ for i, want := range []string{"opencode", "openai-codex"} {
32+ if got := snapshot.Messages[i].Meta["provider"]; got != want {
33+ t.Fatalf("message %d origin = %q, want %q", i, got, want)
34+ }
35+ }
36+ if err := session.AppendCompaction(snapshot.Messages); err != nil {
37+ t.Fatal(err)
38+ }
39+ if err := session.UpdateModel("google", "model-c"); err != nil {
40+ t.Fatal(err)
41+ }
42+ snapshot, err = ReadSessionSnapshot(session.Path)
43+ if err != nil {
44+ t.Fatal(err)
45+ }
46+ if snapshot.Messages[0].Meta["provider"] != "opencode" || snapshot.Messages[1].Meta["model"] != "model-b" {
47+ t.Fatalf("compaction lost origins: %+v", snapshot.Messages)
48+ }
49+ history, err := ReadSessionHistory(session.Path)
50+ if err != nil {
51+ t.Fatal(err)
52+ }
53+ branchPath, err := BranchSessionHiddenFromHistory(session.Path, root, cwd, "test", history.Segments[0], len(history.Segments[0].Messages))
54+ if err != nil {
55+ t.Fatal(err)
56+ }
57+ branch, err := ReadSessionSnapshot(branchPath)
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ if len(branch.Messages) != 2 || branch.Messages[0].Meta["provider"] != "opencode" || branch.Messages[0].Meta["model"] != "model-a" || branch.Messages[1].Meta["provider"] != "openai-codex" || branch.Messages[1].Meta["model"] != "model-b" {
62+ t.Fatalf("historical branch lost origins: %+v", branch.Messages)
63+ }
64+}
65+
66+func TestAgentRecordsMessageOriginAcrossModelSwitch(t *testing.T) {
67+ client := &identityRecordingClient{}
68+ agent := NewAgent(client, "model-a", "system", Registry{})
69+ if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
70+ t.Fatal(err)
71+ }
72+ agent.Model = "model-b"
73+ if err := agent.Prompt(context.Background(), "second", nil, nil); err != nil {
74+ t.Fatal(err)
75+ }
76+ var models []string
77+ for _, msg := range agent.Messages() {
78+ if msg.Role == provider.RoleAssistant {
79+ if msg.Meta["provider"] != client.Name() {
80+ t.Fatal("missing provider origin")
81+ }
82+ models = append(models, msg.Meta["model"])
83+ }
84+ }
85+ if len(models) != 2 || models[0] != "model-a" || models[1] != "model-b" {
86+ t.Fatalf("origins = %v", models)
87+ }
88+}
M packages/provider/gemini.go +12 -6
@@ -31,9 +31,10 @@ const geminiDefaultBaseURL = "https://generativelanguage.googleapis.com"
3131
3232 // geminiClient implements Client against the Gemini Generative Language API.
3333 type geminiClient struct {
34- apiKey string
35- baseURL string
36- http *http.Client
34+ providerName string
35+ apiKey string
36+ baseURL string
37+ http *http.Client
3738 }
3839
3940 // NewGemini creates a Gemini client using an AI Studio API key.
@@ -49,7 +50,12 @@ func NewGemini(apiKey, baseURL string) Client {
4950 }
5051 }
5152
52-func (c *geminiClient) Name() string { return "google" }
53+func (c *geminiClient) Name() string {
54+ if c.providerName != "" {
55+ return c.providerName
56+ }
57+ return "google"
58+}
5359
5460 // ---- wire types ----
5561 //
@@ -205,7 +211,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
205211 }
206212 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
207213 case RoleAssistant:
208- parts := convertGemAssistantParts(msg.Content, functionsEnabled)
214+ parts := convertGemAssistantParts(replayContent(msg, c.Name(), req.Model), functionsEnabled)
209215 if len(parts) == 0 {
210216 continue
211217 }
@@ -523,7 +529,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
523529 defer resp.Body.Close()
524530
525531 model, _ := FindModel("google", req.Model)
526- out <- EventStart{Model: req.Model, Provider: "google"}
532+ out <- EventStart{Model: req.Model, Provider: c.Name()}
527533
528534 raw := make(chan sseEvent, 16)
529535 go readSSE(resp.Body, raw)
@@ -31,9 +31,10 @@ const geminiDefaultBaseURL = "https://generativelanguage.googleapis.com"
31 31
32 // geminiClient implements Client against the Gemini Generative Language API.32 // geminiClient implements Client against the Gemini Generative Language API.
33 type geminiClient struct {33 type geminiClient struct {
34- apiKey string34+ providerName string
35- baseURL string35+ apiKey string
36- http *http.Client36+ baseURL string
37+ http *http.Client
37 }38 }
38 39
39 // NewGemini creates a Gemini client using an AI Studio API key.40 // NewGemini creates a Gemini client using an AI Studio API key.
@@ -49,7 +50,12 @@ func NewGemini(apiKey, baseURL string) Client {
49 }50 }
50 }51 }
51 52
52-func (c *geminiClient) Name() string { return "google" }53+func (c *geminiClient) Name() string {
54+ if c.providerName != "" {
55+ return c.providerName
56+ }
57+ return "google"
58+}
53 59
54 // ---- wire types ----60 // ---- wire types ----
55 //61 //
@@ -205,7 +211,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
205 }211 }
206 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})212 out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts})
207 case RoleAssistant:213 case RoleAssistant:
208- parts := convertGemAssistantParts(msg.Content, functionsEnabled)214+ parts := convertGemAssistantParts(replayContent(msg, c.Name(), req.Model), functionsEnabled)
209 if len(parts) == 0 {215 if len(parts) == 0 {
210 continue216 continue
211 }217 }
@@ -523,7 +529,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R
523 defer resp.Body.Close()529 defer resp.Body.Close()
524 530
525 model, _ := FindModel("google", req.Model)531 model, _ := FindModel("google", req.Model)
526- out <- EventStart{Model: req.Model, Provider: "google"}532+ out <- EventStart{Model: req.Model, Provider: c.Name()}
527 533
528 raw := make(chan sseEvent, 16)534 raw := make(chan sseEvent, 16)
529 go readSSE(resp.Body, raw)535 go readSSE(resp.Body, raw)
M packages/provider/google_vertex.go +4 -3
@@ -346,14 +346,15 @@ func NewVertex(_ string, _ string) Client {
346346 baseHost = cfg.location + "-aiplatform.googleapis.com"
347347 }
348348 inner := &geminiClient{
349- apiKey: "vertex-placeholder", // overwritten by transport
350- baseURL: "https://" + baseHost,
349+ providerName: "google-vertex",
350+ apiKey: "vertex-placeholder", // overwritten by transport
351+ baseURL: "https://" + baseHost,
351352 http: &http.Client{
352353 Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg},
353354 Timeout: 0,
354355 },
355356 }
356- // Wrap so Name() reports "google-vertex" instead of "google".
357+ // Keep the public wrapper and wire client on the same provider identity.
357358 return &renamedClient{inner: inner, name: "google-vertex"}
358359 }
359360
@@ -346,14 +346,15 @@ func NewVertex(_ string, _ string) Client {
346 baseHost = cfg.location + "-aiplatform.googleapis.com"346 baseHost = cfg.location + "-aiplatform.googleapis.com"
347 }347 }
348 inner := &geminiClient{348 inner := &geminiClient{
349- apiKey: "vertex-placeholder", // overwritten by transport349+ providerName: "google-vertex",
350- baseURL: "https://" + baseHost,350+ apiKey: "vertex-placeholder", // overwritten by transport
351+ baseURL: "https://" + baseHost,
351 http: &http.Client{352 http: &http.Client{
352 Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg},353 Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg},
353 Timeout: 0,354 Timeout: 0,
354 },355 },
355 }356 }
356- // Wrap so Name() reports "google-vertex" instead of "google".357+ // Keep the public wrapper and wire client on the same provider identity.
357 return &renamedClient{inner: inner, name: "google-vertex"}358 return &renamedClient{inner: inner, name: "google-vertex"}
358 }359 }
359 360
M packages/provider/openai_codex.go +12 -1
@@ -355,7 +355,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
355355 // is enabled. Blocks without encrypted content are omitted:
356356 // an ID or summary alone is not a self-contained reasoning
357357 // item while store is false. See reasoningForReplay.
358- for _, c := range msg.Content {
358+ for _, c := range replayContent(msg, providerName, req.Model) {
359359 switch v := c.(type) {
360360 case ReasoningBlock:
361361 item, ok := reasoningForReplay(v)
@@ -446,6 +446,17 @@ func reasoningForReplay(block ReasoningBlock) (codexReasoningItem, bool) {
446446 if block.Encrypted == "" {
447447 return codexReasoningItem{}, false
448448 }
449+ // Gateway composite IDs and other providers' signatures are not native
450+ // Responses items. Never rewrite an opaque ID to make it look valid.
451+ for _, ch := range block.ID {
452+ switch {
453+ case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z',
454+ ch >= '0' && ch <= '9', ch == '_', ch == '-':
455+ // Responses item IDs use ASCII letters, digits, underscores, or dashes.
456+ default:
457+ return codexReasoningItem{}, false
458+ }
459+ }
449460 item := codexReasoningItem{
450461 Type: "reasoning",
451462 ID: block.ID,
@@ -355,7 +355,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
355 // is enabled. Blocks without encrypted content are omitted:355 // is enabled. Blocks without encrypted content are omitted:
356 // an ID or summary alone is not a self-contained reasoning356 // an ID or summary alone is not a self-contained reasoning
357 // item while store is false. See reasoningForReplay.357 // item while store is false. See reasoningForReplay.
358- for _, c := range msg.Content {358+ for _, c := range replayContent(msg, providerName, req.Model) {
359 switch v := c.(type) {359 switch v := c.(type) {
360 case ReasoningBlock:360 case ReasoningBlock:
361 item, ok := reasoningForReplay(v)361 item, ok := reasoningForReplay(v)
@@ -446,6 +446,17 @@ func reasoningForReplay(block ReasoningBlock) (codexReasoningItem, bool) {
446 if block.Encrypted == "" {446 if block.Encrypted == "" {
447 return codexReasoningItem{}, false447 return codexReasoningItem{}, false
448 }448 }
449+ // Gateway composite IDs and other providers' signatures are not native
450+ // Responses items. Never rewrite an opaque ID to make it look valid.
451+ for _, ch := range block.ID {
452+ switch {
453+ case ch >= 'a' && ch <= 'z', ch >= 'A' && ch <= 'Z',
454+ ch >= '0' && ch <= '9', ch == '_', ch == '-':
455+ // Responses item IDs use ASCII letters, digits, underscores, or dashes.
456+ default:
457+ return codexReasoningItem{}, false
458+ }
459+ }
449 item := codexReasoningItem{460 item := codexReasoningItem{
450 Type: "reasoning",461 Type: "reasoning",
451 ID: block.ID,462 ID: block.ID,
M packages/provider/openai_responses_websocket.go +4 -1
@@ -498,7 +498,10 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSession
498498 fullRequest := req
499499 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
500500 if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
501- if !sameResponsesOutput(req.Messages[0], s.lastOutput) {
501+ // The host adds route provenance after receiving EventDone.
502+ // Fill only missing origin fields before strict output comparison.
503+ output := WithMessageOrigin(s.lastOutput, req.Messages[0].Meta["provider"], req.Messages[0].Meta["model"])
504+ if !sameResponsesOutput(req.Messages[0], output) {
502505 return fullRequest, ""
503506 }
504507 req.Messages = req.Messages[1:]
@@ -498,7 +498,10 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSession
498 fullRequest := req498 fullRequest := req
499 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)499 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
500 if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {500 if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
501- if !sameResponsesOutput(req.Messages[0], s.lastOutput) {501+ // The host adds route provenance after receiving EventDone.
502+ // Fill only missing origin fields before strict output comparison.
503+ output := WithMessageOrigin(s.lastOutput, req.Messages[0].Meta["provider"], req.Messages[0].Meta["model"])
504+ if !sameResponsesOutput(req.Messages[0], output) {
502 return fullRequest, ""505 return fullRequest, ""
503 }506 }
504 req.Messages = req.Messages[1:]507 req.Messages = req.Messages[1:]
A packages/provider/replay.go +50 -0
@@ -0,0 +1,50 @@
1+package provider
2+
3+// WithMessageOrigin records the producing route without changing shared metadata.
4+// Explicit provenance survives session replay, compaction, and model switches.
5+func WithMessageOrigin(msg Message, providerName, model string) Message {
6+ if msg.Role != RoleAssistant || (providerName == "" && model == "") {
7+ return msg
8+ }
9+ meta := make(map[string]string, len(msg.Meta)+2)
10+ for k, v := range msg.Meta {
11+ meta[k] = v
12+ }
13+ if meta["provider"] == "" && providerName != "" {
14+ meta["provider"] = providerName
15+ }
16+ if meta["model"] == "" && model != "" {
17+ meta["model"] = model
18+ }
19+ msg.Meta = meta
20+ return msg
21+}
22+
23+// replayContent projects model-private state out of foreign assistant turns.
24+// The stored transcript and ordinary text/tool blocks remain intact. Unknown
25+// provenance retains historical behavior; each wire adapter still validates
26+// the shape of opaque items it accepts.
27+func replayContent(msg Message, providerName, model string) []Content {
28+ if (msg.Meta["provider"] == "" || msg.Meta["provider"] == providerName) &&
29+ (msg.Meta["model"] == "" || msg.Meta["model"] == model) {
30+ return msg.Content
31+ }
32+ content := make([]Content, 0, len(msg.Content))
33+ for _, block := range msg.Content {
34+ switch v := block.(type) {
35+ case ReasoningBlock:
36+ continue
37+ case TextBlock:
38+ v.ThoughtSignature = ""
39+ block = v
40+ case ImageBlock:
41+ v.ThoughtSignature = ""
42+ block = v
43+ case ToolCallBlock:
44+ v.ThoughtSignature = ""
45+ block = v
46+ }
47+ content = append(content, block)
48+ }
49+ return content
50+}
@@ -0,0 +1,50 @@
1+package provider
2+
3+// WithMessageOrigin records the producing route without changing shared metadata.
4+// Explicit provenance survives session replay, compaction, and model switches.
5+func WithMessageOrigin(msg Message, providerName, model string) Message {
6+ if msg.Role != RoleAssistant || (providerName == "" && model == "") {
7+ return msg
8+ }
9+ meta := make(map[string]string, len(msg.Meta)+2)
10+ for k, v := range msg.Meta {
11+ meta[k] = v
12+ }
13+ if meta["provider"] == "" && providerName != "" {
14+ meta["provider"] = providerName
15+ }
16+ if meta["model"] == "" && model != "" {
17+ meta["model"] = model
18+ }
19+ msg.Meta = meta
20+ return msg
21+}
22+
23+// replayContent projects model-private state out of foreign assistant turns.
24+// The stored transcript and ordinary text/tool blocks remain intact. Unknown
25+// provenance retains historical behavior; each wire adapter still validates
26+// the shape of opaque items it accepts.
27+func replayContent(msg Message, providerName, model string) []Content {
28+ if (msg.Meta["provider"] == "" || msg.Meta["provider"] == providerName) &&
29+ (msg.Meta["model"] == "" || msg.Meta["model"] == model) {
30+ return msg.Content
31+ }
32+ content := make([]Content, 0, len(msg.Content))
33+ for _, block := range msg.Content {
34+ switch v := block.(type) {
35+ case ReasoningBlock:
36+ continue
37+ case TextBlock:
38+ v.ThoughtSignature = ""
39+ block = v
40+ case ImageBlock:
41+ v.ThoughtSignature = ""
42+ block = v
43+ case ToolCallBlock:
44+ v.ThoughtSignature = ""
45+ block = v
46+ }
47+ content = append(content, block)
48+ }
49+ return content
50+}
A packages/provider/replay_test.go +83 -0
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestReplayContentPreservesHistoryAndRestoresOrigin(t *testing.T) {
9+ original := Message{Role: RoleAssistant, Meta: map[string]string{"provider": "google", "model": "model-a"}, Content: []Content{
10+ ReasoningBlock{Encrypted: "opaque"}, TextBlock{Text: "answer", ThoughtSignature: "text-signature"}, ImageBlock{ThoughtSignature: "image-signature"}, ToolCallBlock{ID: "call_1", Name: "lookup", ThoughtSignature: "tool-signature"},
11+ }}
12+ before, _ := json.Marshal(original)
13+ foreign := replayContent(original, "google", "model-b")
14+ if len(foreign) != 3 || foreign[0].(TextBlock).ThoughtSignature != "" || foreign[1].(ImageBlock).ThoughtSignature != "" || foreign[2].(ToolCallBlock).ThoughtSignature != "" {
15+ t.Fatalf("foreign content = %#v", foreign)
16+ }
17+ restored := replayContent(original, "google", "model-a")
18+ if len(restored) != 4 || restored[0].(ReasoningBlock).Encrypted != "opaque" || restored[1].(TextBlock).ThoughtSignature != "text-signature" {
19+ t.Fatal("switch back lost private state")
20+ }
21+ after, _ := json.Marshal(original)
22+ if string(before) != string(after) {
23+ t.Fatal("history mutated")
24+ }
25+}
26+
27+func TestResponsesContinuationAllowsHostOrigin(t *testing.T) {
28+ prior := Request{Model: "model-a", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}}}
29+ output := Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "answer"}}}
30+ session := responsesWebSocketSession{lastRequest: prior, lastOutput: output, lastResponseID: "resp_1", lastBaseline: "baseline"}
31+ next := prior
32+ next.Messages = append(append([]Message(nil), prior.Messages...), WithMessageOrigin(output, "openai-codex", "model-a"), Message{Role: RoleUser, Content: []Content{TextBlock{Text: "next"}}})
33+ incremental, id := session.incrementalRequest(next, "cache", func(Request, string) (string, error) { return "baseline", nil })
34+ if id != "resp_1" || len(incremental.Messages) != 1 {
35+ t.Fatalf("continuation = %q, %d messages", id, len(incremental.Messages))
36+ }
37+}
38+
39+func TestResponsesReplayAcrossModels(t *testing.T) {
40+ c := newOpenAICodexClient("token", "account", "")
41+ for _, tt := range []struct {
42+ name, origin, model, id string
43+ want bool
44+ }{
45+ {"same model", "openai-codex", "gpt-5.2", "rs_native", true},
46+ {"other provider", "opencode", "gpt-5.2", "rs_first:rs_second", false},
47+ {"foreign valid ID", "opencode", "gpt-5.2", "rs_native", false},
48+ {"other model", "openai-codex", "gpt-5", "rs_native", false},
49+ {"unknown compound ID", "", "", "rs_first:rs_second", false},
50+ } {
51+ t.Run(tt.name, func(t *testing.T) {
52+ messages := []Message{{Role: RoleAssistant, Meta: map[string]string{"provider": tt.origin, "model": tt.model}, Content: []Content{
53+ ReasoningBlock{ID: tt.id, Encrypted: "opaque"}, TextBlock{Text: "answer"},
54+ ToolCallBlock{ID: "call_1", Name: "lookup", Arguments: json.RawMessage(`{}`)},
55+ }}, {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call_1", Content: []Content{TextBlock{Text: "result"}}}}}}
56+ before, _ := json.Marshal(messages)
57+ wire, err := c.buildRequest(Request{Model: "gpt-5.2", Messages: messages})
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ reasoning, text, call, result := 0, 0, 0, 0
62+ for _, item := range wire.Input {
63+ switch item.(type) {
64+ case codexReasoningItem:
65+ reasoning++
66+ case codexOutputMessage:
67+ text++
68+ case codexFunctionCall:
69+ call++
70+ case codexFunctionCallOutput:
71+ result++
72+ }
73+ }
74+ if (reasoning == 1) != tt.want || text != 1 || call != 1 || result != 1 {
75+ t.Fatalf("reasoning/text/call/result = %d/%d/%d/%d", reasoning, text, call, result)
76+ }
77+ after, _ := json.Marshal(messages)
78+ if string(before) != string(after) {
79+ t.Fatal("history mutated")
80+ }
81+ })
82+ }
83+}
@@ -0,0 +1,83 @@
1+package provider
2+
3+import (
4+ "encoding/json"
5+ "testing"
6+)
7+
8+func TestReplayContentPreservesHistoryAndRestoresOrigin(t *testing.T) {
9+ original := Message{Role: RoleAssistant, Meta: map[string]string{"provider": "google", "model": "model-a"}, Content: []Content{
10+ ReasoningBlock{Encrypted: "opaque"}, TextBlock{Text: "answer", ThoughtSignature: "text-signature"}, ImageBlock{ThoughtSignature: "image-signature"}, ToolCallBlock{ID: "call_1", Name: "lookup", ThoughtSignature: "tool-signature"},
11+ }}
12+ before, _ := json.Marshal(original)
13+ foreign := replayContent(original, "google", "model-b")
14+ if len(foreign) != 3 || foreign[0].(TextBlock).ThoughtSignature != "" || foreign[1].(ImageBlock).ThoughtSignature != "" || foreign[2].(ToolCallBlock).ThoughtSignature != "" {
15+ t.Fatalf("foreign content = %#v", foreign)
16+ }
17+ restored := replayContent(original, "google", "model-a")
18+ if len(restored) != 4 || restored[0].(ReasoningBlock).Encrypted != "opaque" || restored[1].(TextBlock).ThoughtSignature != "text-signature" {
19+ t.Fatal("switch back lost private state")
20+ }
21+ after, _ := json.Marshal(original)
22+ if string(before) != string(after) {
23+ t.Fatal("history mutated")
24+ }
25+}
26+
27+func TestResponsesContinuationAllowsHostOrigin(t *testing.T) {
28+ prior := Request{Model: "model-a", Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hello"}}}}}
29+ output := Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "answer"}}}
30+ session := responsesWebSocketSession{lastRequest: prior, lastOutput: output, lastResponseID: "resp_1", lastBaseline: "baseline"}
31+ next := prior
32+ next.Messages = append(append([]Message(nil), prior.Messages...), WithMessageOrigin(output, "openai-codex", "model-a"), Message{Role: RoleUser, Content: []Content{TextBlock{Text: "next"}}})
33+ incremental, id := session.incrementalRequest(next, "cache", func(Request, string) (string, error) { return "baseline", nil })
34+ if id != "resp_1" || len(incremental.Messages) != 1 {
35+ t.Fatalf("continuation = %q, %d messages", id, len(incremental.Messages))
36+ }
37+}
38+
39+func TestResponsesReplayAcrossModels(t *testing.T) {
40+ c := newOpenAICodexClient("token", "account", "")
41+ for _, tt := range []struct {
42+ name, origin, model, id string
43+ want bool
44+ }{
45+ {"same model", "openai-codex", "gpt-5.2", "rs_native", true},
46+ {"other provider", "opencode", "gpt-5.2", "rs_first:rs_second", false},
47+ {"foreign valid ID", "opencode", "gpt-5.2", "rs_native", false},
48+ {"other model", "openai-codex", "gpt-5", "rs_native", false},
49+ {"unknown compound ID", "", "", "rs_first:rs_second", false},
50+ } {
51+ t.Run(tt.name, func(t *testing.T) {
52+ messages := []Message{{Role: RoleAssistant, Meta: map[string]string{"provider": tt.origin, "model": tt.model}, Content: []Content{
53+ ReasoningBlock{ID: tt.id, Encrypted: "opaque"}, TextBlock{Text: "answer"},
54+ ToolCallBlock{ID: "call_1", Name: "lookup", Arguments: json.RawMessage(`{}`)},
55+ }}, {Role: RoleTool, Content: []Content{ToolResultBlock{CallID: "call_1", Content: []Content{TextBlock{Text: "result"}}}}}}
56+ before, _ := json.Marshal(messages)
57+ wire, err := c.buildRequest(Request{Model: "gpt-5.2", Messages: messages})
58+ if err != nil {
59+ t.Fatal(err)
60+ }
61+ reasoning, text, call, result := 0, 0, 0, 0
62+ for _, item := range wire.Input {
63+ switch item.(type) {
64+ case codexReasoningItem:
65+ reasoning++
66+ case codexOutputMessage:
67+ text++
68+ case codexFunctionCall:
69+ call++
70+ case codexFunctionCallOutput:
71+ result++
72+ }
73+ }
74+ if (reasoning == 1) != tt.want || text != 1 || call != 1 || result != 1 {
75+ t.Fatalf("reasoning/text/call/result = %d/%d/%d/%d", reasoning, text, call, result)
76+ }
77+ after, _ := json.Marshal(messages)
78+ if string(before) != string(after) {
79+ t.Fatal("history mutated")
80+ }
81+ })
82+ }
83+}
A packages/provider/vertex_replay_test.go +26 -0
@@ -0,0 +1,26 @@
1+package provider
2+
3+import "testing"
4+
5+func TestVertexReplaysOwnReasoning(t *testing.T) {
6+ t.Setenv("GOOGLE_CLOUD_API_KEY", "synthetic-key")
7+ t.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
8+ client := NewVertex("", "").(*renamedClient)
9+ inner := client.inner.(*geminiClient)
10+ for _, origin := range []string{"google-vertex", "google"} {
11+ t.Run(origin, func(t *testing.T) {
12+ wire, _, err := inner.buildRequest(Request{Model: "gemini-2.5-pro", Messages: []Message{WithMessageOrigin(Message{Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "summary", Encrypted: "opaque"}, TextBlock{Text: "answer", ThoughtSignature: "signature"}}}, origin, "gemini-2.5-pro")}})
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ parts := wire.Contents[0].Parts
17+ if origin == "google-vertex" {
18+ if len(parts) != 2 || parts[0].ThoughtSignature != "opaque" || parts[1].ThoughtSignature != "signature" {
19+ t.Fatalf("own metadata lost: %+v", parts)
20+ }
21+ } else if len(parts) != 1 || parts[0].ThoughtSignature != "" {
22+ t.Fatalf("foreign metadata replayed: %+v", parts)
23+ }
24+ })
25+ }
26+}
@@ -0,0 +1,26 @@
1+package provider
2+
3+import "testing"
4+
5+func TestVertexReplaysOwnReasoning(t *testing.T) {
6+ t.Setenv("GOOGLE_CLOUD_API_KEY", "synthetic-key")
7+ t.Setenv("GOOGLE_CLOUD_PROJECT", "test-project")
8+ client := NewVertex("", "").(*renamedClient)
9+ inner := client.inner.(*geminiClient)
10+ for _, origin := range []string{"google-vertex", "google"} {
11+ t.Run(origin, func(t *testing.T) {
12+ wire, _, err := inner.buildRequest(Request{Model: "gemini-2.5-pro", Messages: []Message{WithMessageOrigin(Message{Role: RoleAssistant, Content: []Content{ReasoningBlock{Summary: "summary", Encrypted: "opaque"}, TextBlock{Text: "answer", ThoughtSignature: "signature"}}}, origin, "gemini-2.5-pro")}})
13+ if err != nil {
14+ t.Fatal(err)
15+ }
16+ parts := wire.Contents[0].Parts
17+ if origin == "google-vertex" {
18+ if len(parts) != 2 || parts[0].ThoughtSignature != "opaque" || parts[1].ThoughtSignature != "signature" {
19+ t.Fatalf("own metadata lost: %+v", parts)
20+ }
21+ } else if len(parts) != 1 || parts[0].ThoughtSignature != "" {
22+ t.Fatalf("foreign metadata replayed: %+v", parts)
23+ }
24+ })
25+ }
26+}

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 477

↑↓ moveenter openesc close