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 Unverified
B5690EEEBB952194The 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 | |||
| 258 | 258 | ||
| 259 | 259 | ### 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 | 265 | ## Local llama.cpp router | |
| 264 | 266 | ||
| @@ -258,7 +258,9 @@ When a Responses completion carries an explicit `end_turn:false`, zut treats the | |||||
| 258 | 258 | ||||
| 259 | ### Responses reasoning replay | 259 | ### 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 router | 265 | ## 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 | |||
| 919 | 919 | maxTail = len(previous) | |
| 920 | 920 | } | |
| 921 | 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 | 933 | continue | |
| 924 | 934 | } | |
| 925 | 935 | 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 | continue | 933 | 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 | |||
| 1155 | 1155 | case provider.EventDone: | |
| 1156 | 1156 | stop = e.Stop | |
| 1157 | 1157 | finalErr = e.Err | |
| 1158 | - | finalMsg = e.Message | |
| 1158 | + | finalMsg = provider.WithMessageOrigin(e.Message, a.Client.Name(), req.Model) | |
| 1159 | 1159 | return true | |
| 1160 | 1160 | } | |
| 1161 | 1161 | 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.Stop | 1156 | stop = e.Stop | ||
| 1157 | finalErr = e.Err | 1157 | finalErr = e.Err | ||
| 1158 | - | finalMsg = e.Message | 1158 | + | finalMsg = provider.WithMessageOrigin(e.Message, a.Client.Name(), req.Model) |
| 1159 | return true | 1159 | return true | ||
| 1160 | } | 1160 | } | ||
| 1161 | return false | 1161 | return false | ||
M packages/core/session.go +6 -2
| @@ -425,7 +425,10 @@ func readSessionSnapshot(ctx context.Context, path string) (SessionSnapshot, err | |||
| 425 | 425 | snapshot.Title = row.meta.Title | |
| 426 | 426 | } | |
| 427 | 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 | 432 | case "compaction": | |
| 430 | 433 | snapshot.Messages = row.messages | |
| 431 | 434 | generation++ | |
| @@ -549,7 +552,8 @@ func readSessionHistory(ctx context.Context, path string) (SessionHistory, error | |||
| 549 | 552 | case "meta": | |
| 550 | 553 | history.Meta = *row.meta | |
| 551 | 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 | 557 | case "compaction": | |
| 554 | 558 | appendCurrent() | |
| 555 | 559 | 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.Title | 425 | 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.messages | 433 | 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.meta | 553 | 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" | |||
| 31 | 31 | ||
| 32 | 32 | // geminiClient implements Client against the Gemini Generative Language API. | |
| 33 | 33 | 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 | |
| 37 | 38 | } | |
| 38 | 39 | ||
| 39 | 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 | 60 | // ---- wire types ---- | |
| 55 | 61 | // | |
| @@ -205,7 +211,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) { | |||
| 205 | 211 | } | |
| 206 | 212 | out.Contents = append(out.Contents, gemContent{Role: "user", Parts: parts}) | |
| 207 | 213 | case RoleAssistant: | |
| 208 | - | parts := convertGemAssistantParts(msg.Content, functionsEnabled) | |
| 214 | + | parts := convertGemAssistantParts(replayContent(msg, c.Name(), req.Model), functionsEnabled) | |
| 209 | 215 | if len(parts) == 0 { | |
| 210 | 216 | continue | |
| 211 | 217 | } | |
| @@ -523,7 +529,7 @@ func (c *geminiClient) runStream(ctx context.Context, resp *http.Response, req R | |||
| 523 | 529 | defer resp.Body.Close() | |
| 524 | 530 | ||
| 525 | 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 | 534 | raw := make(chan sseEvent, 16) | |
| 529 | 535 | 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 string | 34 | + | providerName string |
| 35 | - | baseURL string | 35 | + | apiKey string |
| 36 | - | http *http.Client | 36 | + | 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 | continue | 216 | 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 { | |||
| 346 | 346 | baseHost = cfg.location + "-aiplatform.googleapis.com" | |
| 347 | 347 | } | |
| 348 | 348 | 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, | |
| 351 | 352 | http: &http.Client{ | |
| 352 | 353 | Transport: &vertexTransport{inner: http.DefaultTransport, cfg: cfg}, | |
| 353 | 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 | 358 | return &renamedClient{inner: inner, name: "google-vertex"} | |
| 358 | 359 | } | |
| 359 | 360 | ||
| @@ -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 transport | 349 | + | 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) { | |||
| 355 | 355 | // is enabled. Blocks without encrypted content are omitted: | |
| 356 | 356 | // an ID or summary alone is not a self-contained reasoning | |
| 357 | 357 | // item while store is false. See reasoningForReplay. | |
| 358 | - | for _, c := range msg.Content { | |
| 358 | + | for _, c := range replayContent(msg, providerName, req.Model) { | |
| 359 | 359 | switch v := c.(type) { | |
| 360 | 360 | case ReasoningBlock: | |
| 361 | 361 | item, ok := reasoningForReplay(v) | |
| @@ -446,6 +446,17 @@ func reasoningForReplay(block ReasoningBlock) (codexReasoningItem, bool) { | |||
| 446 | 446 | if block.Encrypted == "" { | |
| 447 | 447 | 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 | 460 | item := codexReasoningItem{ | |
| 450 | 461 | Type: "reasoning", | |
| 451 | 462 | 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 reasoning | 356 | // 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{}, false | 447 | 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 | |||
| 498 | 498 | fullRequest := req | |
| 499 | 499 | req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...) | |
| 500 | 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 | 505 | return fullRequest, "" | |
| 503 | 506 | } | |
| 504 | 507 | req.Messages = req.Messages[1:] | |
| @@ -498,7 +498,10 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSession | |||||
| 498 | fullRequest := req | 498 | 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 | + | } | |||