detached 36aecf0

fix(cache): preserve request routing state

commit
36aecf0b83803171233aee06b165aa2f9bbde286
author
brice · · 2026-08-18 12:10 UTC
parent
38f58a7
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +289 -51
M packages/agent/sdk/types.go +18 -0
@@ -46,6 +46,9 @@ type Event struct {
4646 Usage *Usage `json:"usage,omitempty"`
4747 Cumulative *Usage `json:"cumulative,omitempty"`
4848
49+ // cache_diagnostics
50+ CacheDiagnostics *CacheDiagnostics `json:"cache_diagnostics,omitempty"`
51+
4952 // user_message / assistant_message
5053 Message *Message `json:"message,omitempty"`
5154
@@ -90,6 +93,14 @@ type Image struct {
9093 Data []byte
9194 }
9295
96+// CacheDiagnostics reports sanitized provider cache-routing state.
97+type CacheDiagnostics struct {
98+ Eligible bool `json:"eligible"`
99+ Mode string `json:"mode"`
100+ Transport string `json:"transport"`
101+ Continuation string `json:"continuation"`
102+}
103+
93104 // Usage is per-turn or cumulative token / cost counts.
94105 type Usage struct {
95106 Input int `json:"input"`
@@ -182,6 +193,13 @@ func toEvent(ev core.AgentEvent) Event {
182193 out.ID = e.ID
183194 out.IsError = e.Result.IsError
184195 out.Result = convertContent(e.Result.Content)
196+ case core.EvCacheDiagnostics:
197+ out.CacheDiagnostics = &CacheDiagnostics{
198+ Eligible: e.Eligible,
199+ Mode: e.Mode,
200+ Transport: e.Transport,
201+ Continuation: e.Continuation,
202+ }
185203 case core.EvUsage:
186204 out.Usage = &Usage{
187205 Input: e.Usage.InputTokens,
@@ -46,6 +46,9 @@ type Event struct {
46 Usage *Usage `json:"usage,omitempty"`46 Usage *Usage `json:"usage,omitempty"`
47 Cumulative *Usage `json:"cumulative,omitempty"`47 Cumulative *Usage `json:"cumulative,omitempty"`
48 48
49+ // cache_diagnostics
50+ CacheDiagnostics *CacheDiagnostics `json:"cache_diagnostics,omitempty"`
51+
49 // user_message / assistant_message52 // user_message / assistant_message
50 Message *Message `json:"message,omitempty"`53 Message *Message `json:"message,omitempty"`
51 54
@@ -90,6 +93,14 @@ type Image struct {
90 Data []byte93 Data []byte
91 }94 }
92 95
96+// CacheDiagnostics reports sanitized provider cache-routing state.
97+type CacheDiagnostics struct {
98+ Eligible bool `json:"eligible"`
99+ Mode string `json:"mode"`
100+ Transport string `json:"transport"`
101+ Continuation string `json:"continuation"`
102+}
103+
93 // Usage is per-turn or cumulative token / cost counts.104 // Usage is per-turn or cumulative token / cost counts.
94 type Usage struct {105 type Usage struct {
95 Input int `json:"input"`106 Input int `json:"input"`
@@ -182,6 +193,13 @@ func toEvent(ev core.AgentEvent) Event {
182 out.ID = e.ID193 out.ID = e.ID
183 out.IsError = e.Result.IsError194 out.IsError = e.Result.IsError
184 out.Result = convertContent(e.Result.Content)195 out.Result = convertContent(e.Result.Content)
196+ case core.EvCacheDiagnostics:
197+ out.CacheDiagnostics = &CacheDiagnostics{
198+ Eligible: e.Eligible,
199+ Mode: e.Mode,
200+ Transport: e.Transport,
201+ Continuation: e.Continuation,
202+ }
185 case core.EvUsage:203 case core.EvUsage:
186 out.Usage = &Usage{204 out.Usage = &Usage{
187 Input: e.Usage.InputTokens,205 Input: e.Usage.InputTokens,
M packages/agent/sdk/types_test.go +27 -0
@@ -2,12 +2,39 @@ package sdk
22
33 import (
44 "encoding/json"
5+ "strings"
56 "testing"
67 "time"
78
89 "github.com/bnema/zut/packages/core"
910 )
1011
12+func TestToEventPreservesCacheDiagnostics(t *testing.T) {
13+ for _, diagnostics := range []core.EvCacheDiagnostics{
14+ {Eligible: true, Mode: "automatic", Transport: "http_sse", Continuation: "full"},
15+ {Eligible: false, Mode: "below_minimum", Transport: "websocket", Continuation: "incremental"},
16+ } {
17+ event := toEvent(diagnostics)
18+ if event.Type != "cache_diagnostics" || event.CacheDiagnostics == nil {
19+ t.Fatalf("event = %#v", event)
20+ }
21+ got := event.CacheDiagnostics
22+ if got.Eligible != diagnostics.Eligible || got.Mode != diagnostics.Mode || got.Transport != diagnostics.Transport || got.Continuation != diagnostics.Continuation {
23+ t.Fatalf("cache diagnostics = %#v, want %#v", got, diagnostics)
24+ }
25+ encoded, err := json.Marshal(event)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if !json.Valid(encoded) {
30+ t.Fatalf("encoded diagnostics are invalid JSON: %s", encoded)
31+ }
32+ if !diagnostics.Eligible && !strings.Contains(string(encoded), `"eligible":false`) {
33+ t.Fatalf("encoded ineligible diagnostics = %s, want eligible=false", encoded)
34+ }
35+ }
36+}
37+
1138 func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
1239 retry := toEvent(core.EvRetryScheduled{
1340 Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond,
@@ -2,12 +2,39 @@ package sdk
2 2
3 import (3 import (
4 "encoding/json"4 "encoding/json"
5+ "strings"
5 "testing"6 "testing"
6 "time"7 "time"
7 8
8 "github.com/bnema/zut/packages/core"9 "github.com/bnema/zut/packages/core"
9 )10 )
10 11
12+func TestToEventPreservesCacheDiagnostics(t *testing.T) {
13+ for _, diagnostics := range []core.EvCacheDiagnostics{
14+ {Eligible: true, Mode: "automatic", Transport: "http_sse", Continuation: "full"},
15+ {Eligible: false, Mode: "below_minimum", Transport: "websocket", Continuation: "incremental"},
16+ } {
17+ event := toEvent(diagnostics)
18+ if event.Type != "cache_diagnostics" || event.CacheDiagnostics == nil {
19+ t.Fatalf("event = %#v", event)
20+ }
21+ got := event.CacheDiagnostics
22+ if got.Eligible != diagnostics.Eligible || got.Mode != diagnostics.Mode || got.Transport != diagnostics.Transport || got.Continuation != diagnostics.Continuation {
23+ t.Fatalf("cache diagnostics = %#v, want %#v", got, diagnostics)
24+ }
25+ encoded, err := json.Marshal(event)
26+ if err != nil {
27+ t.Fatal(err)
28+ }
29+ if !json.Valid(encoded) {
30+ t.Fatalf("encoded diagnostics are invalid JSON: %s", encoded)
31+ }
32+ if !diagnostics.Eligible && !strings.Contains(string(encoded), `"eligible":false`) {
33+ t.Fatalf("encoded ineligible diagnostics = %s, want eligible=false", encoded)
34+ }
35+ }
36+}
37+
11 func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {38 func TestToEventPreservesActivityAndToolStreamFacts(t *testing.T) {
12 retry := toEvent(core.EvRetryScheduled{39 retry := toEvent(core.EvRetryScheduled{
13 Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond,40 Scope: core.RetryScopeProvider, Attempt: 2, MaxAttempts: 3, Delay: 250 * time.Millisecond,
M packages/agent/subagents/resident_journal.go +1 -1
@@ -783,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
783783 }
784784 metadata := ResidentMetadata{
785785 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
786- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
786+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
787787 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
788788 }
789789 needsInterruption := state == ResidentQueued || state == ResidentRunning
@@ -783,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
783 }783 }
784 metadata := ResidentMetadata{784 metadata := ResidentMetadata{
785 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,785 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
786- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,786+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
787 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,787 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
788 }788 }
789 needsInterruption := state == ResidentQueued || state == ResidentRunning789 needsInterruption := state == ResidentQueued || state == ResidentRunning
M packages/agent/subagents/resident_journal_test.go +22 -0
@@ -93,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
9393 }
9494 }
9595
96+func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) {
97+ root := t.TempDir()
98+ journal, err := OpenResidentJournal(root, "cache-child")
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"}
103+ if err := journal.Accept(spec, "task"); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := journal.Close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if metadata.RootCacheID != spec.RootCacheID {
114+ t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID)
115+ }
116+}
117+
96118 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
97119 root := t.TempDir()
98120 journal, err := OpenResidentJournal(root, "interrupted-usage")
@@ -93,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
93 }93 }
94 }94 }
95 95
96+func TestReconcileResidentJournalRetainsRootCacheID(t *testing.T) {
97+ root := t.TempDir()
98+ journal, err := OpenResidentJournal(root, "cache-child")
99+ if err != nil {
100+ t.Fatal(err)
101+ }
102+ spec := ResidentChildSpec{ID: "cache-child", SessionID: "child-session", RootCacheID: "root-cache", Provider: "openai", Model: "gpt-test"}
103+ if err := journal.Accept(spec, "task"); err != nil {
104+ t.Fatal(err)
105+ }
106+ if err := journal.Close(); err != nil {
107+ t.Fatal(err)
108+ }
109+ metadata, err := ReconcileResidentJournal(filepath.Join(root, spec.ID))
110+ if err != nil {
111+ t.Fatal(err)
112+ }
113+ if metadata.RootCacheID != spec.RootCacheID {
114+ t.Fatalf("RootCacheID = %q, want %q", metadata.RootCacheID, spec.RootCacheID)
115+ }
116+}
117+
96 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {118 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
97 root := t.TempDir()119 root := t.TempDir()
98 journal, err := OpenResidentJournal(root, "interrupted-usage")120 journal, err := OpenResidentJournal(root, "interrupted-usage")
M packages/core/compact.go +2 -0
@@ -64,6 +64,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
6464 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
6565
6666 req := provider.Request{
67+ Context: a.beginTurn(),
6768 Model: a.Model,
6869 System: summarizationSystem,
6970 MaxTokens: 4096,
@@ -99,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
99100 })
100101 }
101102
103+ a.beginProviderRequest()
102104 stream, err := a.Client.Stream(ctx, req)
103105 if err != nil {
104106 return "", err
@@ -64,6 +64,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
64 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt64 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
65 65
66 req := provider.Request{66 req := provider.Request{
67+ Context: a.beginTurn(),
67 Model: a.Model,68 Model: a.Model,
68 System: summarizationSystem,69 System: summarizationSystem,
69 MaxTokens: 4096,70 MaxTokens: 4096,
@@ -99,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
99 })100 })
100 }101 }
101 102
103+ a.beginProviderRequest()
102 stream, err := a.Client.Stream(ctx, req)104 stream, err := a.Client.Stream(ctx, req)
103 if err != nil {105 if err != nil {
104 return "", err106 return "", err
M packages/core/compact_lifecycle_test.go +18 -0
@@ -71,6 +71,24 @@ func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) {
7171 }
7272 }
7373
74+func TestCompactStartsProviderRequestIdentity(t *testing.T) {
75+ client := &compactLifecycleClient{}
76+ agent := NewAgent(client, "compact-model", "system", Registry{})
77+ agent.SetMessages([]provider.Message{{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
80+ }})
81+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
82+ t.Fatal(err)
83+ }
84+ if client.req.Context.CacheSessionID == "" || client.req.Context.ThreadID == "" || client.req.Context.TurnID == "" {
85+ t.Fatalf("compaction request context = %#v, want all identities", client.req.Context)
86+ }
87+ if err := agent.BindRequestIdentity("other-cache", "other-thread"); err == nil {
88+ t.Fatal("BindRequestIdentity succeeded after compaction provider request")
89+ }
90+}
91+
7492 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
7593 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
7694 agent := NewAgent(client, "compact-model", "system", Registry{})
@@ -71,6 +71,24 @@ func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) {
71 }71 }
72 }72 }
73 73
74+func TestCompactStartsProviderRequestIdentity(t *testing.T) {
75+ client := &compactLifecycleClient{}
76+ agent := NewAgent(client, "compact-model", "system", Registry{})
77+ agent.SetMessages([]provider.Message{{
78+ Role: provider.RoleUser,
79+ Content: []provider.Content{provider.TextBlock{Text: "source"}},
80+ }})
81+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
82+ t.Fatal(err)
83+ }
84+ if client.req.Context.CacheSessionID == "" || client.req.Context.ThreadID == "" || client.req.Context.TurnID == "" {
85+ t.Fatalf("compaction request context = %#v, want all identities", client.req.Context)
86+ }
87+ if err := agent.BindRequestIdentity("other-cache", "other-thread"); err == nil {
88+ t.Fatal("BindRequestIdentity succeeded after compaction provider request")
89+ }
90+}
91+
74 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {92 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
75 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}93 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
76 agent := NewAgent(client, "compact-model", "system", Registry{})94 agent := NewAgent(client, "compact-model", "system", Registry{})
M packages/core/dynamic_context.go +12 -3
@@ -44,17 +44,26 @@ func (a *Agent) appendDynamicContext(turnContext string) {
4444 }
4545 break
4646 }
47- // The first dynamic item must precede the accepted user task. Later
48- // snapshots extend the history at the next model boundary.
47+ // The first dynamic item must precede the accepted user task. Persisting it
48+ // as an append would reverse that order on session reload, so report the
49+ // resulting transcript replacement instead. Later snapshots extend the
50+ // history at the next model boundary and remain ordinary appends.
51+ first := !hasInternalContextMessage(a.messages)
4952 insertAt := len(a.messages)
50- if !hasInternalContextMessage(a.messages) {
53+ if first {
5154 insertAt = 0
5255 }
5356 a.messages = append(a.messages, provider.Message{})
5457 copy(a.messages[insertAt+1:], a.messages[insertAt:])
5558 a.messages[insertAt] = message
5659 a.rev++
60+ onCompacted := a.OnTranscriptCompacted
61+ persisted := append([]provider.Message(nil), a.messages...)
5762 a.mu.Unlock()
63+ if first && onCompacted != nil {
64+ onCompacted(persisted)
65+ return
66+ }
5867 a.fireMessageAppended(message)
5968 }
6069
@@ -44,17 +44,26 @@ func (a *Agent) appendDynamicContext(turnContext string) {
44 }44 }
45 break45 break
46 }46 }
47- // The first dynamic item must precede the accepted user task. Later47+ // The first dynamic item must precede the accepted user task. Persisting it
48- // snapshots extend the history at the next model boundary.48+ // as an append would reverse that order on session reload, so report the
49+ // resulting transcript replacement instead. Later snapshots extend the
50+ // history at the next model boundary and remain ordinary appends.
51+ first := !hasInternalContextMessage(a.messages)
49 insertAt := len(a.messages)52 insertAt := len(a.messages)
50- if !hasInternalContextMessage(a.messages) {53+ if first {
51 insertAt = 054 insertAt = 0
52 }55 }
53 a.messages = append(a.messages, provider.Message{})56 a.messages = append(a.messages, provider.Message{})
54 copy(a.messages[insertAt+1:], a.messages[insertAt:])57 copy(a.messages[insertAt+1:], a.messages[insertAt:])
55 a.messages[insertAt] = message58 a.messages[insertAt] = message
56 a.rev++59 a.rev++
60+ onCompacted := a.OnTranscriptCompacted
61+ persisted := append([]provider.Message(nil), a.messages...)
57 a.mu.Unlock()62 a.mu.Unlock()
63+ if first && onCompacted != nil {
64+ onCompacted(persisted)
65+ return
66+ }
58 a.fireMessageAppended(message)67 a.fireMessageAppended(message)
59 }68 }
60 69
A packages/core/dynamic_context_test.go +45 -0
@@ -0,0 +1,45 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) {
11+ client := &compactLifecycleClient{}
12+ agent := NewAgent(client, "test-model", "system", Registry{})
13+ session, err := NewSession(t.TempDir(), "/workspace", "test", "test-model", "test")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ agent.OnMessageAppended = func(message provider.Message) {
18+ if err := session.AppendMessage(message); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ agent.OnTranscriptCompacted = func(messages []provider.Message) {
23+ if err := session.AppendCompaction(messages); err != nil {
24+ t.Fatal(err)
25+ }
26+ }
27+ agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
28+ return true, "", "accepted extension context"
29+ }
30+ if err := agent.Prompt(context.Background(), "accepted user message", nil, nil); err != nil {
31+ t.Fatal(err)
32+ }
33+ path := session.Path
34+ if err := session.Close(); err != nil {
35+ t.Fatal(err)
36+ }
37+
38+ _, messages, err := OpenSession(path)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser {
43+ t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages)
44+ }
45+}
@@ -0,0 +1,45 @@
1+package core
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+func TestFirstDynamicContextPersistsAsTranscriptReplacement(t *testing.T) {
11+ client := &compactLifecycleClient{}
12+ agent := NewAgent(client, "test-model", "system", Registry{})
13+ session, err := NewSession(t.TempDir(), "/workspace", "test", "test-model", "test")
14+ if err != nil {
15+ t.Fatal(err)
16+ }
17+ agent.OnMessageAppended = func(message provider.Message) {
18+ if err := session.AppendMessage(message); err != nil {
19+ t.Fatal(err)
20+ }
21+ }
22+ agent.OnTranscriptCompacted = func(messages []provider.Message) {
23+ if err := session.AppendCompaction(messages); err != nil {
24+ t.Fatal(err)
25+ }
26+ }
27+ agent.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
28+ return true, "", "accepted extension context"
29+ }
30+ if err := agent.Prompt(context.Background(), "accepted user message", nil, nil); err != nil {
31+ t.Fatal(err)
32+ }
33+ path := session.Path
34+ if err := session.Close(); err != nil {
35+ t.Fatal(err)
36+ }
37+
38+ _, messages, err := OpenSession(path)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser {
43+ t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages)
44+ }
45+}
M packages/core/session.go +1 -1
@@ -1812,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
18121812 if err := json.Unmarshal(rawMessage, &row); err != nil {
18131813 return provider.Message{}, err
18141814 }
1815- if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {
1815+ if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool && row.Role != provider.RoleDeveloper {
18161816 return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
18171817 }
18181818 if len(row.Content) == 0 {
@@ -1812,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1812 if err := json.Unmarshal(rawMessage, &row); err != nil {1812 if err := json.Unmarshal(rawMessage, &row); err != nil {
1813 return provider.Message{}, err1813 return provider.Message{}, err
1814 }1814 }
1815- if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool {1815+ if row.Role != provider.RoleUser && row.Role != provider.RoleAssistant && row.Role != provider.RoleTool && row.Role != provider.RoleDeveloper {
1816 return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)1816 return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
1817 }1817 }
1818 if len(row.Content) == 0 {1818 if len(row.Content) == 0 {
A packages/core/session_developer_test.go +31 -0
@@ -0,0 +1,31 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+func TestOpenSessionHydratesDeveloperMessage(t *testing.T) {
10+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ if err := session.AppendMessage(provider.Message{
15+ Role: provider.RoleDeveloper,
16+ Content: []provider.Content{provider.TextBlock{Text: "host context"}},
17+ }); err != nil {
18+ t.Fatal(err)
19+ }
20+ path := session.Path
21+ if err := session.Close(); err != nil {
22+ t.Fatal(err)
23+ }
24+ _, messages, err := OpenSession(path)
25+ if err != nil {
26+ t.Fatalf("OpenSession: %v", err)
27+ }
28+ if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper {
29+ t.Fatalf("messages = %#v, want one developer message", messages)
30+ }
31+}
@@ -0,0 +1,31 @@
1+package core
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/zut/packages/provider"
7+)
8+
9+func TestOpenSessionHydratesDeveloperMessage(t *testing.T) {
10+ session, err := NewSession(t.TempDir(), "/workspace", "provider", "model", "test")
11+ if err != nil {
12+ t.Fatal(err)
13+ }
14+ if err := session.AppendMessage(provider.Message{
15+ Role: provider.RoleDeveloper,
16+ Content: []provider.Content{provider.TextBlock{Text: "host context"}},
17+ }); err != nil {
18+ t.Fatal(err)
19+ }
20+ path := session.Path
21+ if err := session.Close(); err != nil {
22+ t.Fatal(err)
23+ }
24+ _, messages, err := OpenSession(path)
25+ if err != nil {
26+ t.Fatalf("OpenSession: %v", err)
27+ }
28+ if len(messages) != 1 || messages[0].Role != provider.RoleDeveloper {
29+ t.Fatalf("messages = %#v, want one developer message", messages)
30+ }
31+}
M packages/provider/amazon_bedrock.go +6 -3
@@ -333,9 +333,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
333333 reasoning := ClampReasoningForModel(model, req.Reasoning)
334334 adaptive := usesAdaptiveThinking(model)
335335
336- system, messages := systemWithDeveloperContext(req)
337- if system != "" {
338- sysBlock := map[string]interface{}{"text": system}
336+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
337+ if stableSystem != "" {
338+ sysBlock := map[string]interface{}{"text": stableSystem}
339339 if caching {
340340 // Append cachePoint after the system text so the stable system
341341 // prompt is cached as the first breakpoint.
@@ -344,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
344344 out.System = []map[string]interface{}{sysBlock}
345345 }
346346 }
347+ if dynamicSystem != "" {
348+ out.System = append(out.System, map[string]interface{}{"text": dynamicSystem})
349+ }
347350 if !adaptive {
348351 out.InferenceConfig.Temperature = req.Temperature
349352 }
@@ -333,9 +333,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
333 reasoning := ClampReasoningForModel(model, req.Reasoning)333 reasoning := ClampReasoningForModel(model, req.Reasoning)
334 adaptive := usesAdaptiveThinking(model)334 adaptive := usesAdaptiveThinking(model)
335 335
336- system, messages := systemWithDeveloperContext(req)336+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
337- if system != "" {337+ if stableSystem != "" {
338- sysBlock := map[string]interface{}{"text": system}338+ sysBlock := map[string]interface{}{"text": stableSystem}
339 if caching {339 if caching {
340 // Append cachePoint after the system text so the stable system340 // Append cachePoint after the system text so the stable system
341 // prompt is cached as the first breakpoint.341 // prompt is cached as the first breakpoint.
@@ -344,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
344 out.System = []map[string]interface{}{sysBlock}344 out.System = []map[string]interface{}{sysBlock}
345 }345 }
346 }346 }
347+ if dynamicSystem != "" {
348+ out.System = append(out.System, map[string]interface{}{"text": dynamicSystem})
349+ }
347 if !adaptive {350 if !adaptive {
348 out.InferenceConfig.Temperature = req.Temperature351 out.InferenceConfig.Temperature = req.Temperature
349 }352 }
M packages/provider/anthropic.go +8 -5
@@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246246 out.Temperature = req.Temperature
247247 }
248248
249- system, messages := systemWithDeveloperContext(req)
249+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
250250
251251 // System prompt assembly differs between api-key and OAuth modes.
252252 // OAuth requests MUST begin with the Claude Code identity line or
@@ -272,20 +272,23 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
272272 Text: claudeCodeIdentity,
273273 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
274274 }}
275- if system != "" {
275+ if stableSystem != "" {
276276 out.System = append(out.System, anthSystemBlock{
277277 Type: "text",
278- Text: system,
278+ Text: stableSystem,
279279 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
280280 })
281281 }
282- } else if system != "" {
282+ } else if stableSystem != "" {
283283 out.System = []anthSystemBlock{{
284284 Type: "text",
285- Text: system,
285+ Text: stableSystem,
286286 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
287287 }}
288288 }
289+ if dynamicSystem != "" {
290+ out.System = append(out.System, anthSystemBlock{Type: "text", Text: dynamicSystem})
291+ }
289292
290293 if reasoning != "" && m.Reasoning {
291294 if adaptive {
@@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246 out.Temperature = req.Temperature246 out.Temperature = req.Temperature
247 }247 }
248 248
249- system, messages := systemWithDeveloperContext(req)249+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
250 250
251 // System prompt assembly differs between api-key and OAuth modes.251 // System prompt assembly differs between api-key and OAuth modes.
252 // OAuth requests MUST begin with the Claude Code identity line or252 // OAuth requests MUST begin with the Claude Code identity line or
@@ -272,20 +272,23 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
272 Text: claudeCodeIdentity,272 Text: claudeCodeIdentity,
273 CacheControl: &anthCacheCtrl{Type: "ephemeral"},273 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
274 }}274 }}
275- if system != "" {275+ if stableSystem != "" {
276 out.System = append(out.System, anthSystemBlock{276 out.System = append(out.System, anthSystemBlock{
277 Type: "text",277 Type: "text",
278- Text: system,278+ Text: stableSystem,
279 CacheControl: &anthCacheCtrl{Type: "ephemeral"},279 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
280 })280 })
281 }281 }
282- } else if system != "" {282+ } else if stableSystem != "" {
283 out.System = []anthSystemBlock{{283 out.System = []anthSystemBlock{{
284 Type: "text",284 Type: "text",
285- Text: system,285+ Text: stableSystem,
286 CacheControl: &anthCacheCtrl{Type: "ephemeral"},286 CacheControl: &anthCacheCtrl{Type: "ephemeral"},
287 }}287 }}
288 }288 }
289+ if dynamicSystem != "" {
290+ out.System = append(out.System, anthSystemBlock{Type: "text", Text: dynamicSystem})
291+ }
289 292
290 if reasoning != "" && m.Reasoning {293 if reasoning != "" && m.Reasoning {
291 if adaptive {294 if adaptive {
M packages/provider/developer_context.go +11 -13
@@ -3,11 +3,16 @@ package provider
33 import "strings"
44
55 // systemWithDeveloperContext projects host-authored developer messages onto
6-// providers that expose only a top-level system instruction. OpenAI adapters
7-// preserve RoleDeveloper as a native developer input instead.
8-func systemWithDeveloperContext(req Request) (string, []Message) {
9- messages := make([]Message, 0, len(req.Messages))
10- context := make([]string, 0)
6+// providers that expose only top-level system instructions. Stable instructions
7+// remain separate so cache-aware adapters can place their cache boundary before
8+// the model-only and per-turn context. OpenAI adapters preserve RoleDeveloper
9+// as a native developer input instead.
10+func systemWithDeveloperContext(req Request) (stable, dynamic string, messages []Message) {
11+ messages = make([]Message, 0, len(req.Messages))
12+ context := make([]string, 0, len(req.Messages)+1)
13+ if text := strings.TrimSpace(req.SystemContext); text != "" {
14+ context = append(context, text)
15+ }
1116 for _, message := range req.Messages {
1217 if message.Role != RoleDeveloper {
1318 messages = append(messages, message)
@@ -19,12 +24,5 @@ func systemWithDeveloperContext(req Request) (string, []Message) {
1924 }
2025 }
2126 }
22- if len(context) == 0 {
23- return req.SystemPrompt(), messages
24- }
25- system := strings.TrimSpace(req.SystemPrompt())
26- if system != "" {
27- system += "\n\n"
28- }
29- return system + strings.Join(context, "\n\n"), messages
27+ return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages
3028 }
@@ -3,11 +3,16 @@ package provider
3 import "strings"3 import "strings"
4 4
5 // systemWithDeveloperContext projects host-authored developer messages onto5 // systemWithDeveloperContext projects host-authored developer messages onto
6-// providers that expose only a top-level system instruction. OpenAI adapters6+// providers that expose only top-level system instructions. Stable instructions
7-// preserve RoleDeveloper as a native developer input instead.7+// remain separate so cache-aware adapters can place their cache boundary before
8-func systemWithDeveloperContext(req Request) (string, []Message) {8+// the model-only and per-turn context. OpenAI adapters preserve RoleDeveloper
9- messages := make([]Message, 0, len(req.Messages))9+// as a native developer input instead.
10- context := make([]string, 0)10+func systemWithDeveloperContext(req Request) (stable, dynamic string, messages []Message) {
11+ messages = make([]Message, 0, len(req.Messages))
12+ context := make([]string, 0, len(req.Messages)+1)
13+ if text := strings.TrimSpace(req.SystemContext); text != "" {
14+ context = append(context, text)
15+ }
11 for _, message := range req.Messages {16 for _, message := range req.Messages {
12 if message.Role != RoleDeveloper {17 if message.Role != RoleDeveloper {
13 messages = append(messages, message)18 messages = append(messages, message)
@@ -19,12 +24,5 @@ func systemWithDeveloperContext(req Request) (string, []Message) {
19 }24 }
20 }25 }
21 }26 }
22- if len(context) == 0 {27+ return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages
23- return req.SystemPrompt(), messages
24- }
25- system := strings.TrimSpace(req.SystemPrompt())
26- if system != "" {
27- system += "\n\n"
28- }
29- return system + strings.Join(context, "\n\n"), messages
30 }28 }
M packages/provider/developer_context_test.go +35 -13
@@ -2,34 +2,56 @@ package provider
22
33 import "testing"
44
5-func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) {
6- system, messages := systemWithDeveloperContext(Request{
7- System: "stable instructions",
5+func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) {
6+ stable, dynamic, messages := systemWithDeveloperContext(Request{
7+ System: "stable instructions",
8+ SystemContext: "legacy host context",
89 Messages: []Message{
910 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
1011 {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
1112 },
1213 })
13- if system != "stable instructions\n\ndynamic host context" {
14- t.Fatalf("system = %q", system)
14+ if stable != "stable instructions" {
15+ t.Fatalf("stable = %q", stable)
16+ }
17+ if dynamic != "legacy host context\n\ndynamic host context" {
18+ t.Fatalf("dynamic = %q", dynamic)
1519 }
1620 if len(messages) != 1 || messages[0].Role != RoleUser {
1721 t.Fatalf("messages = %#v, want only the user task", messages)
1822 }
1923 }
2024
21-func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) {
22- system, messages := systemWithDeveloperContext(Request{
23- System: "stable instructions",
24- SystemContext: "legacy host context",
25+func TestAnthropicDeveloperContextFollowsStableCacheBoundary(t *testing.T) {
26+ req, err := (&anthropicClient{}).buildRequest(Request{
27+ Model: "claude-sonnet-4-5",
28+ System: "stable instructions",
29+ Messages: []Message{
30+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
31+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
32+ },
33+ })
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if len(req.System) != 2 || req.System[0].Text != "stable instructions" || req.System[0].CacheControl == nil || req.System[1].Text != "dynamic host context" || req.System[1].CacheControl != nil {
38+ t.Fatalf("system blocks = %#v", req.System)
39+ }
40+}
41+
42+func TestBedrockDeveloperContextFollowsStableCacheBoundary(t *testing.T) {
43+ req, err := (&bedrockClient{region: "us-east-1"}).buildRequest(Request{
44+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
45+ System: "stable instructions",
2546 Messages: []Message{
2647 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
48+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
2749 },
2850 })
29- if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" {
30- t.Fatalf("system = %q", system)
51+ if err != nil {
52+ t.Fatal(err)
3153 }
32- if len(messages) != 0 {
33- t.Fatalf("messages = %#v, want no developer message", messages)
54+ if len(req.System) != 3 || req.System[0]["text"] != "stable instructions" || req.System[1]["cachePoint"] == nil || req.System[2]["text"] != "dynamic host context" {
55+ t.Fatalf("system blocks = %#v", req.System)
3456 }
3557 }
@@ -2,34 +2,56 @@ package provider
2 2
3 import "testing"3 import "testing"
4 4
5-func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) {5+func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) {
6- system, messages := systemWithDeveloperContext(Request{6+ stable, dynamic, messages := systemWithDeveloperContext(Request{
7- System: "stable instructions",7+ System: "stable instructions",
8+ SystemContext: "legacy host context",
8 Messages: []Message{9 Messages: []Message{
9 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},10 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
10 {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},11 {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
11 },12 },
12 })13 })
13- if system != "stable instructions\n\ndynamic host context" {14+ if stable != "stable instructions" {
14- t.Fatalf("system = %q", system)15+ t.Fatalf("stable = %q", stable)
16+ }
17+ if dynamic != "legacy host context\n\ndynamic host context" {
18+ t.Fatalf("dynamic = %q", dynamic)
15 }19 }
16 if len(messages) != 1 || messages[0].Role != RoleUser {20 if len(messages) != 1 || messages[0].Role != RoleUser {
17 t.Fatalf("messages = %#v, want only the user task", messages)21 t.Fatalf("messages = %#v, want only the user task", messages)
18 }22 }
19 }23 }
20 24
21-func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) {25+func TestAnthropicDeveloperContextFollowsStableCacheBoundary(t *testing.T) {
22- system, messages := systemWithDeveloperContext(Request{26+ req, err := (&anthropicClient{}).buildRequest(Request{
23- System: "stable instructions",27+ Model: "claude-sonnet-4-5",
24- SystemContext: "legacy host context",28+ System: "stable instructions",
29+ Messages: []Message{
30+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
31+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
32+ },
33+ })
34+ if err != nil {
35+ t.Fatal(err)
36+ }
37+ if len(req.System) != 2 || req.System[0].Text != "stable instructions" || req.System[0].CacheControl == nil || req.System[1].Text != "dynamic host context" || req.System[1].CacheControl != nil {
38+ t.Fatalf("system blocks = %#v", req.System)
39+ }
40+}
41+
42+func TestBedrockDeveloperContextFollowsStableCacheBoundary(t *testing.T) {
43+ req, err := (&bedrockClient{region: "us-east-1"}).buildRequest(Request{
44+ Model: "anthropic.claude-sonnet-4-5-20250929-v1:0",
45+ System: "stable instructions",
25 Messages: []Message{46 Messages: []Message{
26 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},47 {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
48+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
27 },49 },
28 })50 })
29- if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" {51+ if err != nil {
30- t.Fatalf("system = %q", system)52+ t.Fatal(err)
31 }53 }
32- if len(messages) != 0 {54+ if len(req.System) != 3 || req.System[0]["text"] != "stable instructions" || req.System[1]["cachePoint"] == nil || req.System[2]["text"] != "dynamic host context" {
33- t.Fatalf("messages = %#v, want no developer message", messages)55+ t.Fatalf("system blocks = %#v", req.System)
34 }56 }
35 }57 }
M packages/provider/gemini.go +3 -2
@@ -142,11 +142,12 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
142142 }
143143
144144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145- system, messages := systemWithDeveloperContext(req)
145+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
146+ system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n"))
146147 out := &gemRequest{}
147148
148149 // System prompt → systemInstruction.parts[0].text.
149- if strings.TrimSpace(system) != "" {
150+ if system != "" {
150151 out.SystemInstruction = &gemSystemInstruction{
151152 Parts: []gemPart{{Text: system}},
152153 }
@@ -142,11 +142,12 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
142 }142 }
143 143
144 reasoning := ClampReasoningForModel(m, req.Reasoning)144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145- system, messages := systemWithDeveloperContext(req)145+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
146+ system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n"))
146 out := &gemRequest{}147 out := &gemRequest{}
147 148
148 // System prompt → systemInstruction.parts[0].text.149 // System prompt → systemInstruction.parts[0].text.
149- if strings.TrimSpace(system) != "" {150+ if system != "" {
150 out.SystemInstruction = &gemSystemInstruction{151 out.SystemInstruction = &gemSystemInstruction{
151 Parts: []gemPart{{Text: system}},152 Parts: []gemPart{{Text: system}},
152 }153 }
M packages/provider/openai_responses_websocket.go +7 -10
@@ -188,16 +188,6 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
188188 }
189189 streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline)
190190 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
191- if err == nil {
192- wire, wireErr := c.http.buildRequest(streamReq)
193- if wireErr == nil {
194- continuation := "full"
195- if previousResponseID != "" {
196- continuation = "incremental"
197- }
198- ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
199- }
200- }
201191 if err != nil {
202192 return nil, err
203193 }
@@ -205,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
205195 if !responsesWebSocketHTTPCompatible(c.http.http) {
206196 return c.http.Stream(ctx, req)
207197 }
198+ if wire, wireErr := c.http.buildRequest(streamReq); wireErr == nil {
199+ continuation := "full"
200+ if previousResponseID != "" {
201+ continuation = "incremental"
202+ }
203+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204+ }
208205 reportRequestAttempt(req.Lifecycle, 1, 2)
209206 if err := session.ensureConnected(ctx, c.dial); err != nil {
210207 // A connection that never became usable has no server-side turn. The
@@ -188,16 +188,6 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
188 }188 }
189 streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline)189 streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline)
190 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)190 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
191- if err == nil {
192- wire, wireErr := c.http.buildRequest(streamReq)
193- if wireErr == nil {
194- continuation := "full"
195- if previousResponseID != "" {
196- continuation = "incremental"
197- }
198- ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
199- }
200- }
201 if err != nil {191 if err != nil {
202 return nil, err192 return nil, err
203 }193 }
@@ -205,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
205 if !responsesWebSocketHTTPCompatible(c.http.http) {195 if !responsesWebSocketHTTPCompatible(c.http.http) {
206 return c.http.Stream(ctx, req)196 return c.http.Stream(ctx, req)
207 }197 }
198+ if wire, wireErr := c.http.buildRequest(streamReq); wireErr == nil {
199+ continuation := "full"
200+ if previousResponseID != "" {
201+ continuation = "incremental"
202+ }
203+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
204+ }
208 reportRequestAttempt(req.Lifecycle, 1, 2)205 reportRequestAttempt(req.Lifecycle, 1, 2)
209 if err := session.ensureConnected(ctx, c.dial); err != nil {206 if err := session.ensureConnected(ctx, c.dial); err != nil {
210 // A connection that never became usable has no server-side turn. The207 // A connection that never became usable has no server-side turn. The
M packages/provider/openai_responses_websocket_test.go +42 -0
@@ -3,6 +3,7 @@ package provider
33 import (
44 "context"
55 "encoding/json"
6+ "io"
67 "net/http"
78 "net/http/httptest"
89 "reflect"
@@ -378,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) {
378379 }
379380 }
380381
382+type cacheDiagnosticsLifecycle struct {
383+ diagnostics []CacheDiagnostics
384+}
385+
386+func (l *cacheDiagnosticsLifecycle) RequestAttempt(int, int) {}
387+func (l *cacheDiagnosticsLifecycle) RetryScheduled(int, int, time.Duration) {}
388+
389+func (l *cacheDiagnosticsLifecycle) CacheDiagnostics(diagnostics CacheDiagnostics) {
390+ l.diagnostics = append(l.diagnostics, diagnostics)
391+}
392+
393+func TestResponsesWebSocketUsesHTTPDiagnosticsForIncompatibleTransport(t *testing.T) {
394+ lifecycle := &cacheDiagnosticsLifecycle{}
395+ client := newResponsesWebSocketClient(&codexClient{
396+ token: "test-token", baseURL: "https://example.test/v1/responses", providerName: "openai",
397+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
398+ http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
399+ return &http.Response{
400+ StatusCode: http.StatusOK,
401+ Header: make(http.Header),
402+ Body: io.NopCloser(strings.NewReader(`data: {"type":"response.completed","response":{"status":"completed","usage":{}}}
403+
404+`)),
405+ }, nil
406+ })},
407+ })
408+ req := Request{
409+ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread"}, Lifecycle: lifecycle,
410+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}},
411+ }
412+ stream, err := client.Stream(context.Background(), req)
413+ if err != nil {
414+ t.Fatal(err)
415+ }
416+ for range stream {
417+ }
418+ if len(lifecycle.diagnostics) != 1 || lifecycle.diagnostics[0].Transport != "http_sse" {
419+ t.Fatalf("diagnostics = %#v, want only HTTP/SSE diagnostics", lifecycle.diagnostics)
420+ }
421+}
422+
381423 func assertCompletedResponse(t *testing.T, client Client, req Request) {
382424 assertCompletedResponseText(t, client, req, "ok")
383425 }
@@ -3,6 +3,7 @@ package provider
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "io"
6 "net/http"7 "net/http"
7 "net/http/httptest"8 "net/http/httptest"
8 "reflect"9 "reflect"
@@ -378,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) {
378 }379 }
379 }380 }
380 381
382+type cacheDiagnosticsLifecycle struct {
383+ diagnostics []CacheDiagnostics
384+}
385+
386+func (l *cacheDiagnosticsLifecycle) RequestAttempt(int, int) {}
387+func (l *cacheDiagnosticsLifecycle) RetryScheduled(int, int, time.Duration) {}
388+
389+func (l *cacheDiagnosticsLifecycle) CacheDiagnostics(diagnostics CacheDiagnostics) {
390+ l.diagnostics = append(l.diagnostics, diagnostics)
391+}
392+
393+func TestResponsesWebSocketUsesHTTPDiagnosticsForIncompatibleTransport(t *testing.T) {
394+ lifecycle := &cacheDiagnosticsLifecycle{}
395+ client := newResponsesWebSocketClient(&codexClient{
396+ token: "test-token", baseURL: "https://example.test/v1/responses", providerName: "openai",
397+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
398+ http: &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
399+ return &http.Response{
400+ StatusCode: http.StatusOK,
401+ Header: make(http.Header),
402+ Body: io.NopCloser(strings.NewReader(`data: {"type":"response.completed","response":{"status":"completed","usage":{}}}
403+
404+`)),
405+ }, nil
406+ })},
407+ })
408+ req := Request{
409+ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread"}, Lifecycle: lifecycle,
410+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "request"}}}},
411+ }
412+ stream, err := client.Stream(context.Background(), req)
413+ if err != nil {
414+ t.Fatal(err)
415+ }
416+ for range stream {
417+ }
418+ if len(lifecycle.diagnostics) != 1 || lifecycle.diagnostics[0].Transport != "http_sse" {
419+ t.Fatalf("diagnostics = %#v, want only HTTP/SSE diagnostics", lifecycle.diagnostics)
420+ }
421+}
422+
381 func assertCompletedResponse(t *testing.T, client Client, req Request) {423 func assertCompletedResponse(t *testing.T, client Client, req Request) {
382 assertCompletedResponseText(t, client, req, "ok")424 assertCompletedResponseText(t, client, req, "ok")
383 }425 }

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