detached aa7307c

Merge pull request #140 from bnema/feat/openai-agent-cache-correctness

feat(provider): improve OpenAI cache correctness
commit
aa7307c33f974c7b52a90b04254970285abeb183
author
bnema · · 2026-08-18 12:16 UTC
committer
GitHub · · 2026-08-18 12:16 UTC
merge of
29be61c + a635da7 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
46 files · +1246 -266
M docs/providers.md +27 -11
@@ -150,22 +150,38 @@ profile sets `fastMode` explicitly.
150150
151151 ## Session identity and caching
152152
153-zut supplies every provider request with a stable logical session ID and a
154-per-prompt turn ID. They are opaque local correlations; adapters translate them
155-only when their exact provider, model, and endpoint declaration supports it.
156-For example, the OpenAI/Codex Responses route uses the stable session as its
157-`prompt_cache_key` and, where required by the declared Codex route, its
158-`session-id` header. A generic OpenAI-compatible endpoint never receives those
159-extensions merely because it accepts an OpenAI-shaped request.
153+zut supplies every provider request with a root cache identity, a
154+conversation-thread identity, and a per-prompt turn ID. They are opaque local
155+correlations; adapters translate them only when their exact provider, model,
156+and endpoint declaration supports it. Resident children share the root cache
157+identity but retain distinct thread identities. The OpenAI/Codex Responses
158+route uses the root identity as `prompt_cache_key`; declared Codex routes also
159+receive their documented session and thread routing headers. A generic
160+OpenAI-compatible endpoint never receives these extensions merely because it
161+accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache
162+diagnostics (`eligible`, `mode`, `transport`, and `continuation`). `eligible`
163+is a conservative local prompt-length estimate; OpenAI performs the authoritative
164+tokenization and cache decision. These records never include prompts, durable
165+IDs, request bodies, or credentials.
166+
167+When a provider reports cache detail, zut displays cache-read hit rate as
168+`cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed
169+cache miss; no percentage means the provider or historical session did not
170+report cache detail. Cache writes remain separate from that hit rate. Provider
171+retention and eviction, request routing, and OpenAI's 1,024-token eligibility
172+minimum can still produce a miss even when the stable prefix is unchanged.
160173
161174 ### OpenAI Responses WebSocket mode
162175
163176 For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses
164177 the Responses API WebSocket mode for the OpenAI Responses route. It keeps one
165-connection per logical session, sends one `response.create` at a time, and
166-continues the most recent completed response with `previous_response_id` plus
167-only new tool or user input. The request uses `store:false`; its only
168-provider-specific handshake header is `Authorization: Bearer ...`, never the
178+connection per conversation thread, sends one `response.create` at a time,
179+and continues the most recent completed response with `previous_response_id`
180+only when the complete response-context configuration and prior input prefix
181+match exactly, including the completed assistant output that precedes a new
182+suffix. Compatible continuations send only new tool or user input.
183+The request uses `store:false`; its only provider-specific handshake header is
184+`Authorization: Bearer ...`, never the
169185 ChatGPT/Codex account, routing, or session headers.
170186
171187 The capability is deliberately not inherited by `--base-url`, compatible
@@ -150,22 +150,38 @@ profile sets `fastMode` explicitly.
150 150
151 ## Session identity and caching151 ## Session identity and caching
152 152
153-zut supplies every provider request with a stable logical session ID and a153+zut supplies every provider request with a root cache identity, a
154-per-prompt turn ID. They are opaque local correlations; adapters translate them154+conversation-thread identity, and a per-prompt turn ID. They are opaque local
155-only when their exact provider, model, and endpoint declaration supports it.155+correlations; adapters translate them only when their exact provider, model,
156-For example, the OpenAI/Codex Responses route uses the stable session as its156+and endpoint declaration supports it. Resident children share the root cache
157-`prompt_cache_key` and, where required by the declared Codex route, its157+identity but retain distinct thread identities. The OpenAI/Codex Responses
158-`session-id` header. A generic OpenAI-compatible endpoint never receives those158+route uses the root identity as `prompt_cache_key`; declared Codex routes also
159-extensions merely because it accepts an OpenAI-shaped request.159+receive their documented session and thread routing headers. A generic
160+OpenAI-compatible endpoint never receives these extensions merely because it
161+accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache
162+diagnostics (`eligible`, `mode`, `transport`, and `continuation`). `eligible`
163+is a conservative local prompt-length estimate; OpenAI performs the authoritative
164+tokenization and cache decision. These records never include prompts, durable
165+IDs, request bodies, or credentials.
166+
167+When a provider reports cache detail, zut displays cache-read hit rate as
168+`cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed
169+cache miss; no percentage means the provider or historical session did not
170+report cache detail. Cache writes remain separate from that hit rate. Provider
171+retention and eviction, request routing, and OpenAI's 1,024-token eligibility
172+minimum can still produce a miss even when the stable prefix is unchanged.
160 173
161 ### OpenAI Responses WebSocket mode174 ### OpenAI Responses WebSocket mode
162 175
163 For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses176 For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses
164 the Responses API WebSocket mode for the OpenAI Responses route. It keeps one177 the Responses API WebSocket mode for the OpenAI Responses route. It keeps one
165-connection per logical session, sends one `response.create` at a time, and178+connection per conversation thread, sends one `response.create` at a time,
166-continues the most recent completed response with `previous_response_id` plus179+and continues the most recent completed response with `previous_response_id`
167-only new tool or user input. The request uses `store:false`; its only180+only when the complete response-context configuration and prior input prefix
168-provider-specific handshake header is `Authorization: Bearer ...`, never the181+match exactly, including the completed assistant output that precedes a new
182+suffix. Compatible continuations send only new tool or user input.
183+The request uses `store:false`; its only provider-specific handshake header is
184+`Authorization: Bearer ...`, never the
169 ChatGPT/Codex account, routing, or session headers.185 ChatGPT/Codex account, routing, or session headers.
170 186
171 The capability is deliberately not inherited by `--base-url`, compatible187 The capability is deliberately not inherited by `--base-url`, compatible
M docs/rpc.md +3 -2
@@ -123,7 +123,7 @@ Response data:
123123 "cwd": "/path/to/zut",
124124 "message_count": 12,
125125 "busy": false,
126- "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123}
126+ "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cache_measured_prompt": 1234, "cache_measured_read": 890, "cost_usd": 0.0123}
127127 }
128128 ```
129129
@@ -193,6 +193,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
193193 |---|---|---|
194194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
195195 | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started |
196+| `cache_diagnostics` | `eligible`, `mode`, `transport`, `continuation` | Sanitized cache-routing state. `eligible` is a conservative local prompt-length estimate; it never includes prompts, IDs, request bodies, or credentials |
196197 | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer |
197198 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
198199 | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming |
@@ -205,7 +206,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
205206 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
206207 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
207208 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
208-| `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. `reasoning` is null when unavailable. |
209+| `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. |
209210 | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` |
210211 | `done` | (none) | The whole prompt/compact completed (success or error) |
211212 | `error` | `message` | Non-fatal error message |
@@ -123,7 +123,7 @@ Response data:
123 "cwd": "/path/to/zut",123 "cwd": "/path/to/zut",
124 "message_count": 12,124 "message_count": 12,
125 "busy": false,125 "busy": false,
126- "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cost_usd": 0.0123}126+ "usage": {"input": 1234, "output": 567, "reasoning": 123, "cache_read": 890, "cache_write": 0, "cache_measured_prompt": 1234, "cache_measured_read": 890, "cost_usd": 0.0123}
127 }127 }
128 ```128 ```
129 129
@@ -193,6 +193,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
193 |---|---|---|193 |---|---|---|
194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |194 | `turn_start` | `step` | Beginning of one model call (max-steps loop iteration) |
195 | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started |195 | `request_started` | `provider`, `model`, `scope`, `attempt`, `max_attempts` | A provider (`scope=provider`) or agent (`scope=agent`) request attempt started |
196+| `cache_diagnostics` | `eligible`, `mode`, `transport`, `continuation` | Sanitized cache-routing state. `eligible` is a conservative local prompt-length estimate; it never includes prompts, IDs, request bodies, or credentials |
196 | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer |197 | `retry_scheduled` | `scope`, `attempt`, `max_attempts`, `delay_ms` | The upcoming attempt is delayed; `delay_ms` is a non-negative integer |
197 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |198 | `user_message` | `content`, `time` | The submitted prompt as it was added to the transcript |
198 | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming |199 | `assistant_start` | (none) | Provider connection opened; waiting for assistant streaming |
@@ -205,7 +206,7 @@ Stream notifications during a `prompt` or `compact`. None carry an `id`.
205 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |206 | `tool_progress` | `id`, `text` | Optional progress line from the tool while it runs |
206 | `tool_result` | `id`, `is_error`, `content` | Tool finished |207 | `tool_result` | `id`, `is_error`, `content` | Tool finished |
207 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |208 | `assistant_message` | `content`, `time` | Final assistant message after the model turn ends |
208-| `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. `reasoning` is null when unavailable. |209+| `usage` | `input`, `output`, `reasoning`, `cache_read`, `cache_write`, `cache_measured_prompt`, `cache_measured_read`, `cost_usd`, `cumulative` | Per-turn + cumulative tokens / cost. Cache ratios use only the measured cache fields; `reasoning` is null when unavailable. |
209 | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` |210 | `turn_end` | `stop`, optional `error` | One model call finished. `stop` is `end_turn`, `tool_use`, `length`, `error`, or `aborted` |
210 | `done` | (none) | The whole prompt/compact completed (success or error) |211 | `done` | (none) | The whole prompt/compact completed (success or error) |
211 | `error` | `message` | Non-fatal error message |212 | `error` | `message` | Non-fatal error message |
M packages/agent/modes/json.go +15 -6
@@ -82,6 +82,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
8282 case core.EvAssistantMessage:
8383 m["content"] = ContentToJSON(e.Message.Content)
8484 m["time"] = e.Message.Time
85+ case core.EvCacheDiagnostics:
86+ m["eligible"] = e.Eligible
87+ m["mode"] = e.Mode
88+ m["transport"] = e.Transport
89+ m["continuation"] = e.Continuation
8590 case core.EvTextDelta:
8691 m["delta"] = e.Delta
8792 case core.EvToolUseStart:
@@ -114,14 +119,18 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
114119 m["reasoning"] = nullableReasoningTokens(e.Usage)
115120 m["cache_read"] = e.Usage.CacheReadTokens
116121 m["cache_write"] = e.Usage.CacheWriteTokens
122+ m["cache_measured_prompt"] = e.Usage.CacheMeasuredPromptTokens
123+ m["cache_measured_read"] = e.Usage.CacheMeasuredReadTokens
117124 m["cost_usd"] = e.Usage.CostUSD
118125 m["cumulative"] = map[string]any{
119- "input": e.Cumulative.InputTokens,
120- "output": e.Cumulative.OutputTokens,
121- "reasoning": nullableReasoningTokens(e.Cumulative),
122- "cache_read": e.Cumulative.CacheReadTokens,
123- "cache_write": e.Cumulative.CacheWriteTokens,
124- "cost_usd": e.Cumulative.CostUSD,
126+ "input": e.Cumulative.InputTokens,
127+ "output": e.Cumulative.OutputTokens,
128+ "reasoning": nullableReasoningTokens(e.Cumulative),
129+ "cache_read": e.Cumulative.CacheReadTokens,
130+ "cache_write": e.Cumulative.CacheWriteTokens,
131+ "cache_measured_prompt": e.Cumulative.CacheMeasuredPromptTokens,
132+ "cache_measured_read": e.Cumulative.CacheMeasuredReadTokens,
133+ "cost_usd": e.Cumulative.CostUSD,
125134 }
126135 case core.EvTurnEnd:
127136 m["stop"] = string(e.Stop)
@@ -82,6 +82,11 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
82 case core.EvAssistantMessage:82 case core.EvAssistantMessage:
83 m["content"] = ContentToJSON(e.Message.Content)83 m["content"] = ContentToJSON(e.Message.Content)
84 m["time"] = e.Message.Time84 m["time"] = e.Message.Time
85+ case core.EvCacheDiagnostics:
86+ m["eligible"] = e.Eligible
87+ m["mode"] = e.Mode
88+ m["transport"] = e.Transport
89+ m["continuation"] = e.Continuation
85 case core.EvTextDelta:90 case core.EvTextDelta:
86 m["delta"] = e.Delta91 m["delta"] = e.Delta
87 case core.EvToolUseStart:92 case core.EvToolUseStart:
@@ -114,14 +119,18 @@ func EventToJSON(ev core.AgentEvent) map[string]any {
114 m["reasoning"] = nullableReasoningTokens(e.Usage)119 m["reasoning"] = nullableReasoningTokens(e.Usage)
115 m["cache_read"] = e.Usage.CacheReadTokens120 m["cache_read"] = e.Usage.CacheReadTokens
116 m["cache_write"] = e.Usage.CacheWriteTokens121 m["cache_write"] = e.Usage.CacheWriteTokens
122+ m["cache_measured_prompt"] = e.Usage.CacheMeasuredPromptTokens
123+ m["cache_measured_read"] = e.Usage.CacheMeasuredReadTokens
117 m["cost_usd"] = e.Usage.CostUSD124 m["cost_usd"] = e.Usage.CostUSD
118 m["cumulative"] = map[string]any{125 m["cumulative"] = map[string]any{
119- "input": e.Cumulative.InputTokens,126+ "input": e.Cumulative.InputTokens,
120- "output": e.Cumulative.OutputTokens,127+ "output": e.Cumulative.OutputTokens,
121- "reasoning": nullableReasoningTokens(e.Cumulative),128+ "reasoning": nullableReasoningTokens(e.Cumulative),
122- "cache_read": e.Cumulative.CacheReadTokens,129+ "cache_read": e.Cumulative.CacheReadTokens,
123- "cache_write": e.Cumulative.CacheWriteTokens,130+ "cache_write": e.Cumulative.CacheWriteTokens,
124- "cost_usd": e.Cumulative.CostUSD,131+ "cache_measured_prompt": e.Cumulative.CacheMeasuredPromptTokens,
132+ "cache_measured_read": e.Cumulative.CacheMeasuredReadTokens,
133+ "cost_usd": e.Cumulative.CostUSD,
125 }134 }
126 case core.EvTurnEnd:135 case core.EvTurnEnd:
127 m["stop"] = string(e.Stop)136 m["stop"] = string(e.Stop)
M packages/agent/modes/json_activity_test.go +14 -0
@@ -69,6 +69,20 @@ func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) {
6969 }
7070 }
7171
72+func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) {
73+ event := EventToJSON(core.EvCacheDiagnostics{
74+ Eligible: true, Mode: "implicit", Transport: "websocket", Continuation: "incremental",
75+ })
76+ if event["type"] != "cache_diagnostics" || event["eligible"] != true || event["mode"] != "implicit" || event["transport"] != "websocket" || event["continuation"] != "incremental" {
77+ t.Fatalf("cache diagnostics event = %#v", event)
78+ }
79+ for _, forbidden := range []string{"prompt", "session_id", "thread_id", "request_body", "credential"} {
80+ if _, ok := event[forbidden]; ok {
81+ t.Fatalf("cache diagnostics leaked %q: %#v", forbidden, event)
82+ }
83+ }
84+}
85+
7286 func TestEventToJSONActivityLifecycle(t *testing.T) {
7387 request := EventToJSON(core.EvRequestStarted{
7488 Provider: "anthropic",
@@ -69,6 +69,20 @@ func TestRunJSONFailureEmitsOneErrorObject(t *testing.T) {
69 }69 }
70 }70 }
71 71
72+func TestEventToJSONCacheDiagnosticsAreSanitized(t *testing.T) {
73+ event := EventToJSON(core.EvCacheDiagnostics{
74+ Eligible: true, Mode: "implicit", Transport: "websocket", Continuation: "incremental",
75+ })
76+ if event["type"] != "cache_diagnostics" || event["eligible"] != true || event["mode"] != "implicit" || event["transport"] != "websocket" || event["continuation"] != "incremental" {
77+ t.Fatalf("cache diagnostics event = %#v", event)
78+ }
79+ for _, forbidden := range []string{"prompt", "session_id", "thread_id", "request_body", "credential"} {
80+ if _, ok := event[forbidden]; ok {
81+ t.Fatalf("cache diagnostics leaked %q: %#v", forbidden, event)
82+ }
83+ }
84+}
85+
72 func TestEventToJSONActivityLifecycle(t *testing.T) {86 func TestEventToJSONActivityLifecycle(t *testing.T) {
73 request := EventToJSON(core.EvRequestStarted{87 request := EventToJSON(core.EvRequestStarted{
74 Provider: "anthropic",88 Provider: "anthropic",
M packages/agent/modes/resident_subagent_indicator_test.go +1 -1
@@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
3131 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
3232 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
3333 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},
34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
3535 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
3636 }}, 100, now)
3737 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
@@ -31,7 +31,7 @@ func TestRenderResidentSubagentActivityLinesShowsUsageMetadata(t *testing.T) {
31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)31 now := time.Date(2026, time.August, 13, 12, 0, 0, 0, time.UTC)
32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{32 lines := renderResidentSubagentActivityLines(tui.Dark, "/", []subagents.ResidentSnapshot{{
33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,33 ID: "running-id", Profile: "reviewer", State: subagents.ResidentRunning,
34- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525},34+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
35 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,35 ContextUsed: 45_152, ContextMax: 272_000, Subscription: true,
36 }}, 100, now)36 }}, 100, now)
37 got := strings.Join(plainResidentIndicatorLines(lines), "\n")37 got := strings.Join(plainResidentIndicatorLines(lines), "\n")
M packages/agent/modes/resident_subagents_dialog_test.go +1 -1
@@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
3535 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
3636 journal.ConfigureUsage(272_000, true)
3737 return func(context.Context, string) error {
38- usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}
38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
3939 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
4040 }, nil
4141 })
@@ -35,7 +35,7 @@ func TestResidentSubagentsDialogShowsUsageMetadata(t *testing.T) {
35 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {35 manager := subagents.NewResidentManager(t.TempDir(), func(_ subagents.ResidentChildSpec, journal *subagents.ResidentJournal) (subagents.ResidentTurnRunner, error) {
36 journal.ConfigureUsage(272_000, true)36 journal.ConfigureUsage(272_000, true)
37 return func(context.Context, string) error {37 return func(context.Context, string) error {
38- usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CostUSD: 0.525}38+ usage := provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525}
39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})39 return journal.RecordAgentEvent(core.EvUsage{Usage: usage, Cumulative: usage})
40 }, nil40 }, nil
41 })41 })
M packages/agent/rpc.go +8 -6
@@ -468,12 +468,14 @@ func (s *rpcServer) snapshotState() map[string]any {
468468 "message_count": len(s.agent.Messages()),
469469 "busy": s.activeCancel != nil,
470470 "usage": map[string]any{
471- "input": cum.InputTokens,
472- "output": cum.OutputTokens,
473- "reasoning": rpcReasoningTokens(cum),
474- "cache_read": cum.CacheReadTokens,
475- "cache_write": cum.CacheWriteTokens,
476- "cost_usd": cum.CostUSD,
471+ "input": cum.InputTokens,
472+ "output": cum.OutputTokens,
473+ "reasoning": rpcReasoningTokens(cum),
474+ "cache_read": cum.CacheReadTokens,
475+ "cache_write": cum.CacheWriteTokens,
476+ "cache_measured_prompt": cum.CacheMeasuredPromptTokens,
477+ "cache_measured_read": cum.CacheMeasuredReadTokens,
478+ "cost_usd": cum.CostUSD,
477479 },
478480 }
479481 }
@@ -468,12 +468,14 @@ func (s *rpcServer) snapshotState() map[string]any {
468 "message_count": len(s.agent.Messages()),468 "message_count": len(s.agent.Messages()),
469 "busy": s.activeCancel != nil,469 "busy": s.activeCancel != nil,
470 "usage": map[string]any{470 "usage": map[string]any{
471- "input": cum.InputTokens,471+ "input": cum.InputTokens,
472- "output": cum.OutputTokens,472+ "output": cum.OutputTokens,
473- "reasoning": rpcReasoningTokens(cum),473+ "reasoning": rpcReasoningTokens(cum),
474- "cache_read": cum.CacheReadTokens,474+ "cache_read": cum.CacheReadTokens,
475- "cache_write": cum.CacheWriteTokens,475+ "cache_write": cum.CacheWriteTokens,
476- "cost_usd": cum.CostUSD,476+ "cache_measured_prompt": cum.CacheMeasuredPromptTokens,
477+ "cache_measured_read": cum.CacheMeasuredReadTokens,
478+ "cost_usd": cum.CostUSD,
477 },479 },
478 }480 }
479 }481 }
M packages/agent/sdk/sdk.go +8 -6
@@ -199,12 +199,14 @@ func (r *Runtime) Cost() Usage {
199199 }
200200 u := r.agent.Cost()
201201 return Usage{
202- Input: u.InputTokens,
203- Output: u.OutputTokens,
204- Reasoning: sdkReasoningTokens(u),
205- CacheRead: u.CacheReadTokens,
206- CacheWrite: u.CacheWriteTokens,
207- CostUSD: u.CostUSD,
202+ Input: u.InputTokens,
203+ Output: u.OutputTokens,
204+ Reasoning: sdkReasoningTokens(u),
205+ CacheRead: u.CacheReadTokens,
206+ CacheWrite: u.CacheWriteTokens,
207+ CacheMeasuredPrompt: u.CacheMeasuredPromptTokens,
208+ CacheMeasuredRead: u.CacheMeasuredReadTokens,
209+ CostUSD: u.CostUSD,
208210 }
209211 }
210212
@@ -199,12 +199,14 @@ func (r *Runtime) Cost() Usage {
199 }199 }
200 u := r.agent.Cost()200 u := r.agent.Cost()
201 return Usage{201 return Usage{
202- Input: u.InputTokens,202+ Input: u.InputTokens,
203- Output: u.OutputTokens,203+ Output: u.OutputTokens,
204- Reasoning: sdkReasoningTokens(u),204+ Reasoning: sdkReasoningTokens(u),
205- CacheRead: u.CacheReadTokens,205+ CacheRead: u.CacheReadTokens,
206- CacheWrite: u.CacheWriteTokens,206+ CacheWrite: u.CacheWriteTokens,
207- CostUSD: u.CostUSD,207+ CacheMeasuredPrompt: u.CacheMeasuredPromptTokens,
208+ CacheMeasuredRead: u.CacheMeasuredReadTokens,
209+ CostUSD: u.CostUSD,
208 }210 }
209 }211 }
210 212
M packages/agent/sdk/types.go +42 -18
@@ -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,14 +93,24 @@ 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 {
95- Input int `json:"input"`
96- Output int `json:"output"`
97- Reasoning *int `json:"reasoning"`
98- CacheRead int `json:"cache_read"`
99- CacheWrite int `json:"cache_write"`
100- CostUSD float64 `json:"cost_usd"`
106+ Input int `json:"input"`
107+ Output int `json:"output"`
108+ Reasoning *int `json:"reasoning"`
109+ CacheRead int `json:"cache_read"`
110+ CacheWrite int `json:"cache_write"`
111+ CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"`
112+ CacheMeasuredRead int `json:"cache_measured_read,omitempty"`
113+ CostUSD float64 `json:"cost_usd"`
101114 }
102115
103116 // State is a snapshot of the runtime's current state.
@@ -180,22 +193,33 @@ func toEvent(ev core.AgentEvent) Event {
180193 out.ID = e.ID
181194 out.IsError = e.Result.IsError
182195 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+ }
183203 case core.EvUsage:
184204 out.Usage = &Usage{
185- Input: e.Usage.InputTokens,
186- Output: e.Usage.OutputTokens,
187- Reasoning: sdkReasoningTokens(e.Usage),
188- CacheRead: e.Usage.CacheReadTokens,
189- CacheWrite: e.Usage.CacheWriteTokens,
190- CostUSD: e.Usage.CostUSD,
205+ Input: e.Usage.InputTokens,
206+ Output: e.Usage.OutputTokens,
207+ Reasoning: sdkReasoningTokens(e.Usage),
208+ CacheRead: e.Usage.CacheReadTokens,
209+ CacheWrite: e.Usage.CacheWriteTokens,
210+ CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens,
211+ CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens,
212+ CostUSD: e.Usage.CostUSD,
191213 }
192214 out.Cumulative = &Usage{
193- Input: e.Cumulative.InputTokens,
194- Output: e.Cumulative.OutputTokens,
195- Reasoning: sdkReasoningTokens(e.Cumulative),
196- CacheRead: e.Cumulative.CacheReadTokens,
197- CacheWrite: e.Cumulative.CacheWriteTokens,
198- CostUSD: e.Cumulative.CostUSD,
215+ Input: e.Cumulative.InputTokens,
216+ Output: e.Cumulative.OutputTokens,
217+ Reasoning: sdkReasoningTokens(e.Cumulative),
218+ CacheRead: e.Cumulative.CacheReadTokens,
219+ CacheWrite: e.Cumulative.CacheWriteTokens,
220+ CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens,
221+ CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens,
222+ CostUSD: e.Cumulative.CostUSD,
199223 }
200224 case core.EvTurnEnd:
201225 out.Stop = string(e.Stop)
@@ -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,14 +93,24 @@ 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"`
96- Output int `json:"output"`107+ Output int `json:"output"`
97- Reasoning *int `json:"reasoning"`108+ Reasoning *int `json:"reasoning"`
98- CacheRead int `json:"cache_read"`109+ CacheRead int `json:"cache_read"`
99- CacheWrite int `json:"cache_write"`110+ CacheWrite int `json:"cache_write"`
100- CostUSD float64 `json:"cost_usd"`111+ CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"`
112+ CacheMeasuredRead int `json:"cache_measured_read,omitempty"`
113+ CostUSD float64 `json:"cost_usd"`
101 }114 }
102 115
103 // State is a snapshot of the runtime's current state.116 // State is a snapshot of the runtime's current state.
@@ -180,22 +193,33 @@ func toEvent(ev core.AgentEvent) Event {
180 out.ID = e.ID193 out.ID = e.ID
181 out.IsError = e.Result.IsError194 out.IsError = e.Result.IsError
182 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+ }
183 case core.EvUsage:203 case core.EvUsage:
184 out.Usage = &Usage{204 out.Usage = &Usage{
185- Input: e.Usage.InputTokens,205+ Input: e.Usage.InputTokens,
186- Output: e.Usage.OutputTokens,206+ Output: e.Usage.OutputTokens,
187- Reasoning: sdkReasoningTokens(e.Usage),207+ Reasoning: sdkReasoningTokens(e.Usage),
188- CacheRead: e.Usage.CacheReadTokens,208+ CacheRead: e.Usage.CacheReadTokens,
189- CacheWrite: e.Usage.CacheWriteTokens,209+ CacheWrite: e.Usage.CacheWriteTokens,
190- CostUSD: e.Usage.CostUSD,210+ CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens,
211+ CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens,
212+ CostUSD: e.Usage.CostUSD,
191 }213 }
192 out.Cumulative = &Usage{214 out.Cumulative = &Usage{
193- Input: e.Cumulative.InputTokens,215+ Input: e.Cumulative.InputTokens,
194- Output: e.Cumulative.OutputTokens,216+ Output: e.Cumulative.OutputTokens,
195- Reasoning: sdkReasoningTokens(e.Cumulative),217+ Reasoning: sdkReasoningTokens(e.Cumulative),
196- CacheRead: e.Cumulative.CacheReadTokens,218+ CacheRead: e.Cumulative.CacheReadTokens,
197- CacheWrite: e.Cumulative.CacheWriteTokens,219+ CacheWrite: e.Cumulative.CacheWriteTokens,
198- CostUSD: e.Cumulative.CostUSD,220+ CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens,
221+ CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens,
222+ CostUSD: e.Cumulative.CostUSD,
199 }223 }
200 case core.EvTurnEnd:224 case core.EvTurnEnd:
201 out.Stop = string(e.Stop)225 out.Stop = string(e.Stop)
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/subagent_factory.go +11 -2
@@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
4747 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
4848 agent.SeedCost(baseline.Usage)
4949 }
50- if err := agent.BindSessionID(spec.SessionID); err != nil {
51- return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)
50+ rootCacheID := strings.TrimSpace(spec.RootCacheID)
51+ if rootCacheID == "" {
52+ // Journals written before root cache identity was persisted can still
53+ // resume safely: their immediate parent was the only available root.
54+ rootCacheID = strings.TrimSpace(spec.ParentSessionID)
55+ if rootCacheID == "" {
56+ rootCacheID = spec.SessionID
57+ }
58+ }
59+ if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
60+ return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
5261 }
5362 if journal != nil {
5463 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
@@ -47,8 +47,17 @@ func newResidentChildRunner(args Args, spec subagents.ResidentChildSpec, journal
47 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")47 baseline := journal.ConfigureUsage(resolved.ContextWindow, resolved.AuthMethod == "oauth")
48 agent.SeedCost(baseline.Usage)48 agent.SeedCost(baseline.Usage)
49 }49 }
50- if err := agent.BindSessionID(spec.SessionID); err != nil {50+ rootCacheID := strings.TrimSpace(spec.RootCacheID)
51- return nil, fmt.Errorf("bind resident child %q session: %w", spec.ID, err)51+ if rootCacheID == "" {
52+ // Journals written before root cache identity was persisted can still
53+ // resume safely: their immediate parent was the only available root.
54+ rootCacheID = strings.TrimSpace(spec.ParentSessionID)
55+ if rootCacheID == "" {
56+ rootCacheID = spec.SessionID
57+ }
58+ }
59+ if err := agent.BindRequestIdentity(rootCacheID, spec.SessionID); err != nil {
60+ return nil, fmt.Errorf("bind resident child %q request identity: %w", spec.ID, err)
52 }61 }
53 if journal != nil {62 if journal != nil {
54 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())63 messages, err := subagents.ReadResidentTranscriptMessages(journal.Dir())
M packages/agent/subagent_runtime.go +1 -1
@@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
338338 workspaceMode = subagents.WorkspaceShared
339339 }
340340 spec := subagents.ResidentChildSpec{
341- ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,
341+ ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
342342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
344344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
@@ -338,7 +338,7 @@ func (rt *subagentRuntime) buildResidentChildSpec(_ context.Context, request too
338 workspaceMode = subagents.WorkspaceShared338 workspaceMode = subagents.WorkspaceShared
339 }339 }
340 spec := subagents.ResidentChildSpec{340 spec := subagents.ResidentChildSpec{
341- ID: uuid.NewString(), SessionID: uuid.NewString(), ParentSessionID: parentSession,341+ ID: uuid.NewString(), SessionID: uuid.NewString(), RootCacheID: parentSession, ParentSessionID: parentSession,
342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),342 Provider: strings.TrimSpace(providerID), BaseURL: strings.TrimSpace(baseURL), InsecureTLS: insecureTLS, Model: strings.TrimSpace(model),
343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,343 Reasoning: strings.TrimSpace(reasoning), FastMode: fastMode, Tools: childTools,
344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,344 RepositoryRoot: workspace, Workspace: workspace, WorkspaceMode: workspaceMode, Required: request.Required,
M packages/agent/subagents/resident_journal.go +7 -3
@@ -65,8 +65,11 @@ const (
6565 // provider endpoint and TLS mode are retained so explicit resume reconstructs
6666 // the same transport contract.
6767 type ResidentChildSpec struct {
68- ID string `json:"id"`
68+ ID string `json:"id"`
69+ // SessionID identifies the child's conversation thread. RootCacheID is the
70+ // root cache affinity shared by every child of the same parent session.
6971 SessionID string `json:"session_id"`
72+ RootCacheID string `json:"root_cache_id,omitempty"`
7073 InitialTurnID string `json:"initial_turn_id,omitempty"`
7174 ParentSessionID string `json:"parent_session_id,omitempty"`
7275 Profile string `json:"profile,omitempty"`
@@ -193,6 +196,7 @@ type ResidentMetadata struct {
193196 Version int `json:"version"`
194197 ID string `json:"id"`
195198 SessionID string `json:"session_id"`
199+ RootCacheID string `json:"root_cache_id,omitempty"`
196200 ParentSessionID string `json:"parent_session_id,omitempty"`
197201 State ResidentState `json:"state"`
198202 UpdatedAt time.Time `json:"updated_at"`
@@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
491495 usage := j.usageSnapshot()
492496 return ResidentMetadata{
493497 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
498+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495499 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496500 }
497501 }
@@ -779,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
779783 }
780784 metadata := ResidentMetadata{
781785 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
782- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
786+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
783787 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
784788 }
785789 needsInterruption := state == ResidentQueued || state == ResidentRunning
@@ -65,8 +65,11 @@ const (
65 // provider endpoint and TLS mode are retained so explicit resume reconstructs65 // provider endpoint and TLS mode are retained so explicit resume reconstructs
66 // the same transport contract.66 // the same transport contract.
67 type ResidentChildSpec struct {67 type ResidentChildSpec struct {
68- ID string `json:"id"`68+ ID string `json:"id"`
69+ // SessionID identifies the child's conversation thread. RootCacheID is the
70+ // root cache affinity shared by every child of the same parent session.
69 SessionID string `json:"session_id"`71 SessionID string `json:"session_id"`
72+ RootCacheID string `json:"root_cache_id,omitempty"`
70 InitialTurnID string `json:"initial_turn_id,omitempty"`73 InitialTurnID string `json:"initial_turn_id,omitempty"`
71 ParentSessionID string `json:"parent_session_id,omitempty"`74 ParentSessionID string `json:"parent_session_id,omitempty"`
72 Profile string `json:"profile,omitempty"`75 Profile string `json:"profile,omitempty"`
@@ -193,6 +196,7 @@ type ResidentMetadata struct {
193 Version int `json:"version"`196 Version int `json:"version"`
194 ID string `json:"id"`197 ID string `json:"id"`
195 SessionID string `json:"session_id"`198 SessionID string `json:"session_id"`
199+ RootCacheID string `json:"root_cache_id,omitempty"`
196 ParentSessionID string `json:"parent_session_id,omitempty"`200 ParentSessionID string `json:"parent_session_id,omitempty"`
197 State ResidentState `json:"state"`201 State ResidentState `json:"state"`
198 UpdatedAt time.Time `json:"updated_at"`202 UpdatedAt time.Time `json:"updated_at"`
@@ -491,7 +495,7 @@ func (j *ResidentJournal) metadata(spec ResidentChildSpec, state ResidentState,
491 usage := j.usageSnapshot()495 usage := j.usageSnapshot()
492 return ResidentMetadata{496 return ResidentMetadata{
493 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,497 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
494- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,498+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: updatedAt,
495 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,499 Usage: usage.Usage, ContextUsed: usage.ContextUsed, ContextMax: usage.ContextMax, Subscription: usage.Subscription,
496 }500 }
497 }501 }
@@ -779,7 +783,7 @@ func reconcileOwnedResidentJournal(journal *ResidentJournal) (ResidentMetadata,
779 }783 }
780 metadata := ResidentMetadata{784 metadata := ResidentMetadata{
781 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,785 Version: residentJournalVersion, ID: spec.ID, SessionID: spec.SessionID,
782- ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,786+ RootCacheID: spec.RootCacheID, ParentSessionID: spec.ParentSessionID, State: state, UpdatedAt: lastStateAt,
783 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,
784 }788 }
785 needsInterruption := state == ResidentQueued || state == ResidentRunning789 needsInterruption := state == ResidentQueued || state == ResidentRunning
M packages/agent/subagents/resident_journal_test.go +25 -2
@@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
2525 spec := ResidentChildSpec{
2626 ID: "child-1",
2727 SessionID: "child-session-1",
28+ RootCacheID: "root-cache-1",
2829 ParentSessionID: "parent-session-1",
2930 Provider: "openai-codex",
3031 Model: "gpt-5.6-terra",
@@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
4243 if len(records) != 1 || records[0].Type != residentRecordAccepted {
4344 t.Fatalf("records = %#v, want one accepted record", records)
4445 }
45- if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" {
46+ if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
4647 t.Fatalf("accepted record = %#v", records[0])
4748 }
4849
@@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
5051 if err != nil {
5152 t.Fatalf("ReadResidentMetadata: %v", err)
5253 }
53- if meta.State != ResidentQueued || meta.SessionID != spec.SessionID {
54+ if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
5455 t.Fatalf("metadata = %#v", meta)
5556 }
5657 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
@@ -92,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
9293 }
9394 }
9495
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+
95118 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
96119 root := t.TempDir()
97120 journal, err := OpenResidentJournal(root, "interrupted-usage")
@@ -25,6 +25,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
25 spec := ResidentChildSpec{25 spec := ResidentChildSpec{
26 ID: "child-1",26 ID: "child-1",
27 SessionID: "child-session-1",27 SessionID: "child-session-1",
28+ RootCacheID: "root-cache-1",
28 ParentSessionID: "parent-session-1",29 ParentSessionID: "parent-session-1",
29 Provider: "openai-codex",30 Provider: "openai-codex",
30 Model: "gpt-5.6-terra",31 Model: "gpt-5.6-terra",
@@ -42,7 +43,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
42 if len(records) != 1 || records[0].Type != residentRecordAccepted {43 if len(records) != 1 || records[0].Type != residentRecordAccepted {
43 t.Fatalf("records = %#v, want one accepted record", records)44 t.Fatalf("records = %#v, want one accepted record", records)
44 }45 }
45- if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Prompt != "review this change" {46+ if records[0].Spec == nil || records[0].Spec.SessionID != spec.SessionID || records[0].Spec.RootCacheID != spec.RootCacheID || records[0].Prompt != "review this change" {
46 t.Fatalf("accepted record = %#v", records[0])47 t.Fatalf("accepted record = %#v", records[0])
47 }48 }
48 49
@@ -50,7 +51,7 @@ func TestResidentJournalAcceptCommitsAuthorityBeforeMetadataProjection(t *testin
50 if err != nil {51 if err != nil {
51 t.Fatalf("ReadResidentMetadata: %v", err)52 t.Fatalf("ReadResidentMetadata: %v", err)
52 }53 }
53- if meta.State != ResidentQueued || meta.SessionID != spec.SessionID {54+ if meta.State != ResidentQueued || meta.SessionID != spec.SessionID || meta.RootCacheID != spec.RootCacheID {
54 t.Fatalf("metadata = %#v", meta)55 t.Fatalf("metadata = %#v", meta)
55 }56 }
56 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))57 info, err := os.Stat(filepath.Join(root, "child-1", residentTranscriptName))
@@ -92,6 +93,28 @@ func TestResidentJournalReconcilesUsageMetadata(t *testing.T) {
92 }93 }
93 }94 }
94 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+
95 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {118 func TestResidentJournalProjectsUsageEmittedAfterInterruption(t *testing.T) {
96 root := t.TempDir()119 root := t.TempDir()
97 journal, err := OpenResidentJournal(root, "interrupted-usage")120 journal, err := OpenResidentJournal(root, "interrupted-usage")
M packages/agent/subagents/resident_manager_test.go +5 -4
@@ -792,10 +792,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
792792 }, nil
793793 })
794794 defer manager.Close(context.Background())
795- for _, id := range []string{"first", "second"} {
796- if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: id, SessionID: id, Provider: "openai", Model: "gpt-5"}, id); err != nil {
797- t.Fatalf("Spawn(%s): %v", id, err)
798- }
795+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {
796+ t.Fatalf("Spawn(first): %v", err)
799797 }
800798 select {
801799 case got := <-started:
@@ -805,6 +803,9 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
805803 case <-time.After(time.Second):
806804 t.Fatal("first runner did not start")
807805 }
806+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "second", SessionID: "second", Provider: "openai", Model: "gpt-5"}, "second"); err != nil {
807+ t.Fatalf("Spawn(second): %v", err)
808+ }
808809 deadline := time.Now().Add(time.Second)
809810 for {
810811 result, err := manager.Result("second")
@@ -792,10 +792,8 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
792 }, nil792 }, nil
793 })793 })
794 defer manager.Close(context.Background())794 defer manager.Close(context.Background())
795- for _, id := range []string{"first", "second"} {795+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "first", SessionID: "first", Provider: "openai", Model: "gpt-5"}, "first"); err != nil {
796- if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: id, SessionID: id, Provider: "openai", Model: "gpt-5"}, id); err != nil {796+ t.Fatalf("Spawn(first): %v", err)
797- t.Fatalf("Spawn(%s): %v", id, err)
798- }
799 }797 }
800 select {798 select {
801 case got := <-started:799 case got := <-started:
@@ -805,6 +803,9 @@ func TestResidentManagerAppliesPositiveQueueTimeoutDurably(t *testing.T) {
805 case <-time.After(time.Second):803 case <-time.After(time.Second):
806 t.Fatal("first runner did not start")804 t.Fatal("first runner did not start")
807 }805 }
806+ if _, err := manager.Spawn(context.Background(), ResidentChildSpec{ID: "second", SessionID: "second", Provider: "openai", Model: "gpt-5"}, "second"); err != nil {
807+ t.Fatalf("Spawn(second): %v", err)
808+ }
808 deadline := time.Now().Add(time.Second)809 deadline := time.Now().Add(time.Second)
809 for {810 for {
810 result, err := manager.Result("second")811 result, err := manager.Result("second")
M packages/core/agent.go +60 -26
@@ -164,12 +164,15 @@ type Agent struct {
164164 // boundaries: before the next model call after a tool batch, or
165165 // after a text-only assistant turn finishes. It never interrupts
166166 // a running tool or cancels an in-flight provider request.
167- queued []queuedMessage
168- timeContext agentTimeContext
169- // sessionID is assigned by the host from the durable session before the
170- // first provider request. Agents without persistence allocate one lazily at
171- // their first accepted turn and retain it for the process lifetime.
167+ queued []queuedMessage
168+ timeContext agentTimeContext
169+ hasSessionTimeContext bool
170+ // sessionID identifies this conversation thread. cacheSessionID identifies
171+ // the root prompt-cache affinity and may be shared by resident child
172+ // threads. Hosts bind both before the first provider request; unbound
173+ // agents allocate one shared ephemeral value for the process lifetime.
172174 sessionID string
175+ cacheSessionID string
173176 providerRequestStarted bool
174177 }
175178
@@ -188,37 +191,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
188191 }
189192 }
190193
191-// BindSessionID assigns the stable logical session identity before the first
192-// provider request. A durable host session must bind its SessionMeta.ID here;
193-// no-session hosts may leave it unset and the agent will allocate one lazily.
194+// BindSessionID binds a root conversation's durable session as both its
195+// thread identity and prompt-cache affinity. Resident children use
196+// BindRequestIdentity to retain their own thread while sharing their root's
197+// cache affinity.
194198 func (a *Agent) BindSessionID(id string) error {
195- id = strings.TrimSpace(id)
196- if id == "" {
197- return errors.New("session ID is empty")
199+ return a.BindRequestIdentity(id, id)
200+}
201+
202+// BindRequestIdentity assigns the stable root cache and thread identities
203+// before the first provider request.
204+func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error {
205+ cacheSessionID = strings.TrimSpace(cacheSessionID)
206+ threadID = strings.TrimSpace(threadID)
207+ if cacheSessionID == "" || threadID == "" {
208+ return errors.New("cache session ID and thread ID are required")
198209 }
199210 a.mu.Lock()
200211 defer a.mu.Unlock()
201212 if a.providerRequestStarted {
202- return errors.New("cannot change session ID after the first provider request")
213+ return errors.New("cannot change request identity after the first provider request")
203214 }
204- a.sessionID = id
215+ a.cacheSessionID = cacheSessionID
216+ a.sessionID = threadID
205217 return nil
206218 }
207219
208-// SessionID returns the stable logical session identity, allocating an
220+// SessionID returns the stable conversation-thread identity, allocating an
209221 // ephemeral identity when persistence is disabled and the host did not bind
210222 // one during runtime assembly.
211223 func (a *Agent) SessionID() string {
212224 a.mu.Lock()
213225 defer a.mu.Unlock()
226+ a.ensureRequestIdentityLocked()
227+ return a.sessionID
228+}
229+
230+// CacheSessionID returns the stable root cache-affinity identity.
231+func (a *Agent) CacheSessionID() string {
232+ a.mu.Lock()
233+ defer a.mu.Unlock()
234+ a.ensureRequestIdentityLocked()
235+ return a.cacheSessionID
236+}
237+
238+func (a *Agent) ensureRequestIdentityLocked() {
214239 if a.sessionID == "" {
215240 a.sessionID = uuid.NewString()
216241 }
217- return a.sessionID
242+ if a.cacheSessionID == "" {
243+ a.cacheSessionID = a.sessionID
244+ }
218245 }
219246
220247 func (a *Agent) beginTurn() provider.RequestContext {
221- return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()}
248+ a.mu.Lock()
249+ a.ensureRequestIdentityLocked()
250+ context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()}
251+ a.mu.Unlock()
252+ return context
222253 }
223254
224255 func (a *Agent) beginProviderRequest() {
@@ -593,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
593624 }
594625 }
595626
627+ a.appendDynamicContext(turnContext)
628+
596629 var (
597630 stop provider.StopReason
598631 assistantMsg provider.Message
@@ -866,6 +899,15 @@ type requestLifecycleSink struct {
866899 lastReason RetryReason
867900 }
868901
902+func (s *requestLifecycleSink) CacheDiagnostics(diagnostics provider.CacheDiagnostics) {
903+ s.sink(EvCacheDiagnostics{
904+ Eligible: diagnostics.Eligible,
905+ Mode: diagnostics.Mode,
906+ Transport: diagnostics.Transport,
907+ Continuation: diagnostics.Continuation,
908+ })
909+}
910+
869911 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
870912 s.sink(EvRequestStarted{
871913 Provider: s.provider,
@@ -958,13 +1000,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
9581000 return provider.StopError, provider.Message{}, err
9591001 }
9601002 system, tools := a.PromptConfig()
961- systemContext := a.providerTimeContext().systemText()
962- if contextText := boundedTurnContext(turnContext); contextText != "" {
963- if system != "" {
964- system += "\n\n"
965- }
966- system += "[Extension context for this turn]\n" + contextText
967- }
9681003 // Repair pairs before projecting the copied provider-input view. The
9691004 // repair can add stub results for aborted calls; those results must also
9701005 // remain in the request so every tool call still has a matching result.
@@ -976,9 +1011,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
9761011 model: a.Model,
9771012 }
9781013 req := provider.Request{
979- Model: a.Model,
980- System: system,
981- SystemContext: systemContext,
1014+ Model: a.Model,
1015+ System: system,
9821016 // Repair any dangling tool_use blocks before sending. A turn
9831017 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
9841018 // dev server, etc.) can leave an assistant tool_use with no
@@ -164,12 +164,15 @@ type Agent struct {
164 // boundaries: before the next model call after a tool batch, or164 // boundaries: before the next model call after a tool batch, or
165 // after a text-only assistant turn finishes. It never interrupts165 // after a text-only assistant turn finishes. It never interrupts
166 // a running tool or cancels an in-flight provider request.166 // a running tool or cancels an in-flight provider request.
167- queued []queuedMessage167+ queued []queuedMessage
168- timeContext agentTimeContext168+ timeContext agentTimeContext
169- // sessionID is assigned by the host from the durable session before the169+ hasSessionTimeContext bool
170- // first provider request. Agents without persistence allocate one lazily at170+ // sessionID identifies this conversation thread. cacheSessionID identifies
171- // their first accepted turn and retain it for the process lifetime.171+ // the root prompt-cache affinity and may be shared by resident child
172+ // threads. Hosts bind both before the first provider request; unbound
173+ // agents allocate one shared ephemeral value for the process lifetime.
172 sessionID string174 sessionID string
175+ cacheSessionID string
173 providerRequestStarted bool176 providerRequestStarted bool
174 }177 }
175 178
@@ -188,37 +191,65 @@ func NewAgent(client provider.Client, model, system string, tools Registry) *Age
188 }191 }
189 }192 }
190 193
191-// BindSessionID assigns the stable logical session identity before the first194+// BindSessionID binds a root conversation's durable session as both its
192-// provider request. A durable host session must bind its SessionMeta.ID here;195+// thread identity and prompt-cache affinity. Resident children use
193-// no-session hosts may leave it unset and the agent will allocate one lazily.196+// BindRequestIdentity to retain their own thread while sharing their root's
197+// cache affinity.
194 func (a *Agent) BindSessionID(id string) error {198 func (a *Agent) BindSessionID(id string) error {
195- id = strings.TrimSpace(id)199+ return a.BindRequestIdentity(id, id)
196- if id == "" {200+}
197- return errors.New("session ID is empty")201+
202+// BindRequestIdentity assigns the stable root cache and thread identities
203+// before the first provider request.
204+func (a *Agent) BindRequestIdentity(cacheSessionID, threadID string) error {
205+ cacheSessionID = strings.TrimSpace(cacheSessionID)
206+ threadID = strings.TrimSpace(threadID)
207+ if cacheSessionID == "" || threadID == "" {
208+ return errors.New("cache session ID and thread ID are required")
198 }209 }
199 a.mu.Lock()210 a.mu.Lock()
200 defer a.mu.Unlock()211 defer a.mu.Unlock()
201 if a.providerRequestStarted {212 if a.providerRequestStarted {
202- return errors.New("cannot change session ID after the first provider request")213+ return errors.New("cannot change request identity after the first provider request")
203 }214 }
204- a.sessionID = id215+ a.cacheSessionID = cacheSessionID
216+ a.sessionID = threadID
205 return nil217 return nil
206 }218 }
207 219
208-// SessionID returns the stable logical session identity, allocating an220+// SessionID returns the stable conversation-thread identity, allocating an
209 // ephemeral identity when persistence is disabled and the host did not bind221 // ephemeral identity when persistence is disabled and the host did not bind
210 // one during runtime assembly.222 // one during runtime assembly.
211 func (a *Agent) SessionID() string {223 func (a *Agent) SessionID() string {
212 a.mu.Lock()224 a.mu.Lock()
213 defer a.mu.Unlock()225 defer a.mu.Unlock()
226+ a.ensureRequestIdentityLocked()
227+ return a.sessionID
228+}
229+
230+// CacheSessionID returns the stable root cache-affinity identity.
231+func (a *Agent) CacheSessionID() string {
232+ a.mu.Lock()
233+ defer a.mu.Unlock()
234+ a.ensureRequestIdentityLocked()
235+ return a.cacheSessionID
236+}
237+
238+func (a *Agent) ensureRequestIdentityLocked() {
214 if a.sessionID == "" {239 if a.sessionID == "" {
215 a.sessionID = uuid.NewString()240 a.sessionID = uuid.NewString()
216 }241 }
217- return a.sessionID242+ if a.cacheSessionID == "" {
243+ a.cacheSessionID = a.sessionID
244+ }
218 }245 }
219 246
220 func (a *Agent) beginTurn() provider.RequestContext {247 func (a *Agent) beginTurn() provider.RequestContext {
221- return provider.RequestContext{SessionID: a.SessionID(), TurnID: uuid.NewString()}248+ a.mu.Lock()
249+ a.ensureRequestIdentityLocked()
250+ context := provider.RequestContext{CacheSessionID: a.cacheSessionID, ThreadID: a.sessionID, TurnID: uuid.NewString()}
251+ a.mu.Unlock()
252+ return context
222 }253 }
223 254
224 func (a *Agent) beginProviderRequest() {255 func (a *Agent) beginProviderRequest() {
@@ -593,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
593 }624 }
594 }625 }
595 626
627+ a.appendDynamicContext(turnContext)
628+
596 var (629 var (
597 stop provider.StopReason630 stop provider.StopReason
598 assistantMsg provider.Message631 assistantMsg provider.Message
@@ -866,6 +899,15 @@ type requestLifecycleSink struct {
866 lastReason RetryReason899 lastReason RetryReason
867 }900 }
868 901
902+func (s *requestLifecycleSink) CacheDiagnostics(diagnostics provider.CacheDiagnostics) {
903+ s.sink(EvCacheDiagnostics{
904+ Eligible: diagnostics.Eligible,
905+ Mode: diagnostics.Mode,
906+ Transport: diagnostics.Transport,
907+ Continuation: diagnostics.Continuation,
908+ })
909+}
910+
869 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {911 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
870 s.sink(EvRequestStarted{912 s.sink(EvRequestStarted{
871 Provider: s.provider,913 Provider: s.provider,
@@ -958,13 +1000,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
958 return provider.StopError, provider.Message{}, err1000 return provider.StopError, provider.Message{}, err
959 }1001 }
960 system, tools := a.PromptConfig()1002 system, tools := a.PromptConfig()
961- systemContext := a.providerTimeContext().systemText()
962- if contextText := boundedTurnContext(turnContext); contextText != "" {
963- if system != "" {
964- system += "\n\n"
965- }
966- system += "[Extension context for this turn]\n" + contextText
967- }
968 // Repair pairs before projecting the copied provider-input view. The1003 // Repair pairs before projecting the copied provider-input view. The
969 // repair can add stub results for aborted calls; those results must also1004 // repair can add stub results for aborted calls; those results must also
970 // remain in the request so every tool call still has a matching result.1005 // remain in the request so every tool call still has a matching result.
@@ -976,9 +1011,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
976 model: a.Model,1011 model: a.Model,
977 }1012 }
978 req := provider.Request{1013 req := provider.Request{
979- Model: a.Model,1014+ Model: a.Model,
980- System: system,1015+ System: system,
981- SystemContext: systemContext,
982 // Repair any dangling tool_use blocks before sending. A turn1016 // Repair any dangling tool_use blocks before sending. A turn
983 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a1017 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
984 // dev server, etc.) can leave an assistant tool_use with no1018 // dev server, etc.) can leave an assistant tool_use with no
M packages/core/agent_identity_test.go +9 -9
@@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request {
4949 return requests
5050 }
5151
52-func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
52+func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) {
5353 client := &identityRecordingClient{failOnce: true}
5454 agent := NewAgent(client, "test-model", "system", Registry{})
5555 agent.MaxRetries = 1
5656 agent.RetryBaseDelay = time.Millisecond
57- if err := agent.BindSessionID("session-1"); err != nil {
58- t.Fatalf("BindSessionID: %v", err)
57+ if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil {
58+ t.Fatalf("BindRequestIdentity: %v", err)
5959 }
6060
6161 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
@@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
7070 t.Fatalf("request count = %d, want 3", len(requests))
7171 }
7272 for i, request := range requests {
73- if request.Context.SessionID != "session-1" {
74- t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID)
73+ if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" {
74+ t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context)
7575 }
7676 if request.Context.TurnID == "" {
7777 t.Fatalf("request %d has empty turn ID", i)
@@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
8383 if requests[1].Context.TurnID == requests[2].Context.TurnID {
8484 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)
8585 }
86- if err := agent.BindSessionID("other-session"); err == nil {
87- t.Fatal("BindSessionID succeeded after the first provider request")
86+ if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil {
87+ t.Fatal("BindRequestIdentity succeeded after the first provider request")
8888 }
8989 }
9090
@@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) {
102102 if len(requests) != 2 {
103103 t.Fatalf("request count = %d, want 2", len(requests))
104104 }
105- if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID {
106- t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID)
105+ if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID {
106+ t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context)
107107 }
108108 }
@@ -49,13 +49,13 @@ func (c *identityRecordingClient) Requests() []provider.Request {
49 return requests49 return requests
50 }50 }
51 51
52-func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {52+func TestAgentRequestContextSeparatesCacheThreadAndTurnIdentities(t *testing.T) {
53 client := &identityRecordingClient{failOnce: true}53 client := &identityRecordingClient{failOnce: true}
54 agent := NewAgent(client, "test-model", "system", Registry{})54 agent := NewAgent(client, "test-model", "system", Registry{})
55 agent.MaxRetries = 155 agent.MaxRetries = 1
56 agent.RetryBaseDelay = time.Millisecond56 agent.RetryBaseDelay = time.Millisecond
57- if err := agent.BindSessionID("session-1"); err != nil {57+ if err := agent.BindRequestIdentity("cache-root-1", "thread-1"); err != nil {
58- t.Fatalf("BindSessionID: %v", err)58+ t.Fatalf("BindRequestIdentity: %v", err)
59 }59 }
60 60
61 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {61 if err := agent.Prompt(context.Background(), "first", nil, nil); err != nil {
@@ -70,8 +70,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
70 t.Fatalf("request count = %d, want 3", len(requests))70 t.Fatalf("request count = %d, want 3", len(requests))
71 }71 }
72 for i, request := range requests {72 for i, request := range requests {
73- if request.Context.SessionID != "session-1" {73+ if request.Context.CacheSessionID != "cache-root-1" || request.Context.ThreadID != "thread-1" {
74- t.Fatalf("request %d session ID = %q, want session-1", i, request.Context.SessionID)74+ t.Fatalf("request %d identity = %+v, want cache-root-1/thread-1", i, request.Context)
75 }75 }
76 if request.Context.TurnID == "" {76 if request.Context.TurnID == "" {
77 t.Fatalf("request %d has empty turn ID", i)77 t.Fatalf("request %d has empty turn ID", i)
@@ -83,8 +83,8 @@ func TestAgentRequestContextPreservesSessionAndTurnIdentities(t *testing.T) {
83 if requests[1].Context.TurnID == requests[2].Context.TurnID {83 if requests[1].Context.TurnID == requests[2].Context.TurnID {
84 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)84 t.Fatalf("prompt turn IDs = %q and %q, want distinct IDs", requests[1].Context.TurnID, requests[2].Context.TurnID)
85 }85 }
86- if err := agent.BindSessionID("other-session"); err == nil {86+ if err := agent.BindRequestIdentity("other-cache-root", "other-thread"); err == nil {
87- t.Fatal("BindSessionID succeeded after the first provider request")87+ t.Fatal("BindRequestIdentity succeeded after the first provider request")
88 }88 }
89 }89 }
90 90
@@ -102,7 +102,7 @@ func TestAgentNoSessionIdentityIsStableForRun(t *testing.T) {
102 if len(requests) != 2 {102 if len(requests) != 2 {
103 t.Fatalf("request count = %d, want 2", len(requests))103 t.Fatalf("request count = %d, want 2", len(requests))
104 }104 }
105- if requests[0].Context.SessionID == "" || requests[0].Context.SessionID != requests[1].Context.SessionID {105+ if requests[0].Context.CacheSessionID == "" || requests[0].Context.CacheSessionID != requests[1].Context.CacheSessionID || requests[0].Context.ThreadID != requests[0].Context.CacheSessionID || requests[1].Context.ThreadID != requests[1].Context.CacheSessionID {
106- t.Fatalf("session IDs = %q and %q, want stable non-empty identity", requests[0].Context.SessionID, requests[1].Context.SessionID)106+ t.Fatalf("request identities = %+v and %+v, want stable shared fallback identity", requests[0].Context, requests[1].Context)
107 }107 }
108 }108 }
M packages/core/agent_retry_test.go +7 -9
@@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha
489489 return out, nil
490490 }
491491
492-func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
492+func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) {
493493 client := &captureClient{}
494494 a := NewAgent(client, "fake-model", "system", Registry{})
495495 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
@@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
498498 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
499499 t.Fatalf("Prompt returned %v", err)
500500 }
501- if !strings.Contains(client.lastReq.System, "current phase: parse files") {
502- t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)
501+ if strings.Contains(client.lastReq.System, "current phase: parse files") {
502+ t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System)
503503 }
504- if len(a.Messages()) != 2 {
505- t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))
504+ if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") {
505+ t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages)
506506 }
507- for _, msg := range a.Messages() {
508- if strings.Contains(extractText(msg), "current phase: parse files") {
509- t.Fatal("hidden turn context leaked into transcript")
510- }
507+ if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper {
508+ t.Fatalf("persisted transcript = %#v, want developer context", a.Messages())
511509 }
512510 }
513511
@@ -489,7 +489,7 @@ func (c *captureClient) Stream(ctx context.Context, req provider.Request) (<-cha
489 return out, nil489 return out, nil
490 }490 }
491 491
492-func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {492+func TestAgentPersistsTurnContextAsDeveloperHistory(t *testing.T) {
493 client := &captureClient{}493 client := &captureClient{}
494 a := NewAgent(client, "fake-model", "system", Registry{})494 a := NewAgent(client, "fake-model", "system", Registry{})
495 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {495 a.BeforeTurnContext = func(context.Context, int) (bool, string, string) {
@@ -498,16 +498,14 @@ func TestAgentInjectsHiddenTurnContextIntoRequestOnly(t *testing.T) {
498 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {498 if err := a.Prompt(context.Background(), "hello", nil, nil); err != nil {
499 t.Fatalf("Prompt returned %v", err)499 t.Fatalf("Prompt returned %v", err)
500 }500 }
501- if !strings.Contains(client.lastReq.System, "current phase: parse files") {501+ if strings.Contains(client.lastReq.System, "current phase: parse files") {
502- t.Fatalf("request system prompt = %q; missing hidden context", client.lastReq.System)502+ t.Fatalf("stable request system prompt = %q; dynamic context must not rewrite it", client.lastReq.System)
503 }503 }
504- if len(a.Messages()) != 2 {504+ if len(client.lastReq.Messages) != 2 || client.lastReq.Messages[0].Role != provider.RoleDeveloper || !strings.Contains(extractText(client.lastReq.Messages[0]), "current phase: parse files") {
505- t.Fatalf("transcript message count = %d; hidden context must not be persisted", len(a.Messages()))505+ t.Fatalf("request context history = %#v, want developer context before user task", client.lastReq.Messages)
506 }506 }
507- for _, msg := range a.Messages() {507+ if len(a.Messages()) != 3 || a.Messages()[0].Role != provider.RoleDeveloper {
508- if strings.Contains(extractText(msg), "current phase: parse files") {508+ t.Fatalf("persisted transcript = %#v, want developer context", a.Messages())
509- t.Fatal("hidden turn context leaked into transcript")
510- }
511 }509 }
512 }510 }
513 511
M packages/core/compact.go +12 -8
@@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
3737 msgs := append([]provider.Message(nil), a.messages...)
3838 a.mu.Unlock()
3939
40+ latestContext, msgs := latestInternalContext(msgs)
4041 if len(msgs) == 0 {
4142 return "", fmt.Errorf("nothing to compact")
4243 }
@@ -62,14 +63,13 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
6263
6364 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
6465
65- systemContext := a.providerTimeContext().systemText()
6666 req := provider.Request{
67- Model: a.Model,
68- System: summarizationSystem,
69- SystemContext: systemContext,
70- MaxTokens: 4096,
71- Temperature: a.Temperature,
72- FastMode: fastMode,
67+ Context: a.beginTurn(),
68+ Model: a.Model,
69+ System: summarizationSystem,
70+ MaxTokens: 4096,
71+ Temperature: a.Temperature,
72+ FastMode: fastMode,
7373 Messages: []provider.Message{
7474 {
7575 Role: provider.RoleUser,
@@ -100,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
100100 })
101101 }
102102
103+ a.beginProviderRequest()
103104 stream, err := a.Client.Stream(ctx, req)
104105 if err != nil {
105106 return "", err
@@ -171,7 +172,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
171172 // a tool_use ID that doesn't exist.
172173 tail = repairOrphanedToolResults(tail)
173174
174- next := make([]provider.Message, 0, 1+len(tail))
175+ next := make([]provider.Message, 0, 2+len(tail))
176+ if latestContext != nil {
177+ next = append(next, *latestContext)
178+ }
175179 next = append(next, synthetic)
176180 next = append(next, tail...)
177181
@@ -37,6 +37,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
37 msgs := append([]provider.Message(nil), a.messages...)37 msgs := append([]provider.Message(nil), a.messages...)
38 a.mu.Unlock()38 a.mu.Unlock()
39 39
40+ latestContext, msgs := latestInternalContext(msgs)
40 if len(msgs) == 0 {41 if len(msgs) == 0 {
41 return "", fmt.Errorf("nothing to compact")42 return "", fmt.Errorf("nothing to compact")
42 }43 }
@@ -62,14 +63,13 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
62 63
63 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt64 prompt := "<conversation>\n" + transcript + "\n</conversation>\n\n" + compactionPrompt
64 65
65- systemContext := a.providerTimeContext().systemText()
66 req := provider.Request{66 req := provider.Request{
67- Model: a.Model,67+ Context: a.beginTurn(),
68- System: summarizationSystem,68+ Model: a.Model,
69- SystemContext: systemContext,69+ System: summarizationSystem,
70- MaxTokens: 4096,70+ MaxTokens: 4096,
71- Temperature: a.Temperature,71+ Temperature: a.Temperature,
72- FastMode: fastMode,72+ FastMode: fastMode,
73 Messages: []provider.Message{73 Messages: []provider.Message{
74 {74 {
75 Role: provider.RoleUser,75 Role: provider.RoleUser,
@@ -100,6 +100,7 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
100 })100 })
101 }101 }
102 102
103+ a.beginProviderRequest()
103 stream, err := a.Client.Stream(ctx, req)104 stream, err := a.Client.Stream(ctx, req)
104 if err != nil {105 if err != nil {
105 return "", err106 return "", err
@@ -171,7 +172,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
171 // a tool_use ID that doesn't exist.172 // a tool_use ID that doesn't exist.
172 tail = repairOrphanedToolResults(tail)173 tail = repairOrphanedToolResults(tail)
173 174
174- next := make([]provider.Message, 0, 1+len(tail))175+ next := make([]provider.Message, 0, 2+len(tail))
176+ if latestContext != nil {
177+ next = append(next, *latestContext)
178+ }
175 next = append(next, synthetic)179 next = append(next, synthetic)
176 next = append(next, tail...)180 next = append(next, tail...)
177 181
M packages/core/compact_lifecycle_test.go +36 -0
@@ -53,6 +53,42 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
5353 return out, nil
5454 }
5555
56+func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) {
57+ client := &compactLifecycleClient{}
58+ agent := NewAgent(client, "compact-model", "system", Registry{})
59+ agent.SetMessages([]provider.Message{
60+ {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}},
61+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
62+ {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}},
63+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
64+ })
65+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
66+ t.Fatal(err)
67+ }
68+ messages := agent.Messages()
69+ if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" {
70+ t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages)
71+ }
72+}
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+
5692 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
5793 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
5894 agent := NewAgent(client, "compact-model", "system", Registry{})
@@ -53,6 +53,42 @@ func (c *compactLifecycleClient) Stream(_ context.Context, req provider.Request)
53 return out, nil53 return out, nil
54 }54 }
55 55
56+func TestCompactPreservesOnlyLatestInternalContext(t *testing.T) {
57+ client := &compactLifecycleClient{}
58+ agent := NewAgent(client, "compact-model", "system", Registry{})
59+ agent.SetMessages([]provider.Message{
60+ {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "old"}}, Meta: map[string]string{internalContextMarker: "true"}},
61+ {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "first"}}},
62+ {Role: provider.RoleDeveloper, Content: []provider.Content{provider.TextBlock{Text: "new"}}, Meta: map[string]string{internalContextMarker: "true"}},
63+ {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "answer"}}},
64+ })
65+ if _, err := agent.Compact(context.Background(), 0, nil); err != nil {
66+ t.Fatal(err)
67+ }
68+ messages := agent.Messages()
69+ if len(messages) != 2 || messages[0].Role != provider.RoleDeveloper || internalContextText(messages[0]) != "new" {
70+ t.Fatalf("compacted messages = %#v, want latest internal context then summary", messages)
71+ }
72+}
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+
56 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {92 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
57 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}}}
58 agent := NewAgent(client, "compact-model", "system", Registry{})94 agent := NewAgent(client, "compact-model", "system", Registry{})
M packages/core/context_budget_test.go +1 -1
@@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
4646 if first != second {
4747 t.Fatalf("time context changed between requests: %q != %q", first, second)
4848 }
49- for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
49+ for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
5050 if !strings.Contains(first, want) {
5151 t.Fatalf("time context %q missing %q", first, want)
5252 }
@@ -46,7 +46,7 @@ func TestAgentProviderTimeContextIsStableAndLocal(t *testing.T) {
46 if first != second {46 if first != second {
47 t.Fatalf("time context changed between requests: %q != %q", first, second)47 t.Fatalf("time context changed between requests: %q != %q", first, second)
48 }48 }
49- for _, want := range []string{"session_started: 2026-08-08T02:10:11-07:00", "local_timezone: America/Los_Angeles (UTC-07:00)"} {49+ for _, want := range []string{"session_started: 2026-08-08 02:10", "local_timezone: America/Los_Angeles (UTC-07:00)"} {
50 if !strings.Contains(first, want) {50 if !strings.Contains(first, want) {
51 t.Fatalf("time context %q missing %q", first, want)51 t.Fatalf("time context %q missing %q", first, want)
52 }52 }
M packages/core/cost.go +1 -1
@@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage {
3131 // Seed replaces the running total with usage restored from a session.
3232 func (c *CostTracker) Seed(u provider.Usage) {
3333 c.Total = u
34- c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 0
34+ c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0
3535 }
@@ -31,5 +31,5 @@ func (c *CostTracker) Add(u provider.Usage) provider.Usage {
31 // Seed replaces the running total with usage restored from a session.31 // Seed replaces the running total with usage restored from a session.
32 func (c *CostTracker) Seed(u provider.Usage) {32 func (c *CostTracker) Seed(u provider.Usage) {
33 c.Total = u33 c.Total = u
34- c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CostUSD != 034+ c.hasTotal = u.InputTokens != 0 || u.OutputTokens != 0 || u.CacheReadTokens != 0 || u.CacheWriteTokens != 0 || u.CacheMeasuredPromptTokens != 0 || u.CacheMeasuredReadTokens != 0 || u.CostUSD != 0
35 }35 }
A packages/core/dynamic_context.go +103 -0
@@ -0,0 +1,103 @@
1+package core
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+const internalContextMarker = "internal_context"
11+
12+// appendDynamicContext persists a provider-visible, host-authored context item
13+// only when its canonical value changes. It is deliberately separate from the
14+// stable top-level instructions so changing time or extension state cannot
15+// rewrite the instruction cache prefix.
16+func (a *Agent) appendDynamicContext(turnContext string) {
17+ parts := make([]string, 0, 2)
18+ if timeContext := a.providerTimeContext().developerText(); timeContext != "" {
19+ parts = append(parts, timeContext)
20+ }
21+ if turnContext = boundedTurnContext(turnContext); turnContext != "" {
22+ parts = append(parts, "[Extension context]\n"+turnContext)
23+ }
24+ text := strings.Join(parts, "\n\n")
25+ if text == "" {
26+ return
27+ }
28+
29+ message := provider.Message{
30+ Role: provider.RoleDeveloper,
31+ Content: []provider.Content{provider.TextBlock{Text: text}},
32+ Time: time.Now(),
33+ Meta: map[string]string{internalContextMarker: "true"},
34+ }
35+
36+ a.mu.Lock()
37+ for i := len(a.messages) - 1; i >= 0; i-- {
38+ if !isInternalContextMessage(a.messages[i]) {
39+ continue
40+ }
41+ if internalContextText(a.messages[i]) == text {
42+ a.mu.Unlock()
43+ return
44+ }
45+ break
46+ }
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)
52+ insertAt := len(a.messages)
53+ if first {
54+ insertAt = 0
55+ }
56+ a.messages = append(a.messages, provider.Message{})
57+ copy(a.messages[insertAt+1:], a.messages[insertAt:])
58+ a.messages[insertAt] = message
59+ a.rev++
60+ onCompacted := a.OnTranscriptCompacted
61+ persisted := append([]provider.Message(nil), a.messages...)
62+ a.mu.Unlock()
63+ if first && onCompacted != nil {
64+ onCompacted(persisted)
65+ return
66+ }
67+ a.fireMessageAppended(message)
68+}
69+
70+func hasInternalContextMessage(messages []provider.Message) bool {
71+ for _, message := range messages {
72+ if isInternalContextMessage(message) {
73+ return true
74+ }
75+ }
76+ return false
77+}
78+
79+func isInternalContextMessage(message provider.Message) bool {
80+ return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true"
81+}
82+
83+func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) {
84+ filtered := make([]provider.Message, 0, len(messages))
85+ var latest *provider.Message
86+ for _, message := range messages {
87+ if isInternalContextMessage(message) {
88+ copy := message
89+ latest = &copy
90+ continue
91+ }
92+ filtered = append(filtered, message)
93+ }
94+ return latest, filtered
95+}
96+
97+func internalContextText(message provider.Message) string {
98+ if len(message.Content) != 1 {
99+ return ""
100+ }
101+ text, _ := message.Content[0].(provider.TextBlock)
102+ return text.Text
103+}
@@ -0,0 +1,103 @@
1+package core
2+
3+import (
4+ "strings"
5+ "time"
6+
7+ "github.com/bnema/zut/packages/provider"
8+)
9+
10+const internalContextMarker = "internal_context"
11+
12+// appendDynamicContext persists a provider-visible, host-authored context item
13+// only when its canonical value changes. It is deliberately separate from the
14+// stable top-level instructions so changing time or extension state cannot
15+// rewrite the instruction cache prefix.
16+func (a *Agent) appendDynamicContext(turnContext string) {
17+ parts := make([]string, 0, 2)
18+ if timeContext := a.providerTimeContext().developerText(); timeContext != "" {
19+ parts = append(parts, timeContext)
20+ }
21+ if turnContext = boundedTurnContext(turnContext); turnContext != "" {
22+ parts = append(parts, "[Extension context]\n"+turnContext)
23+ }
24+ text := strings.Join(parts, "\n\n")
25+ if text == "" {
26+ return
27+ }
28+
29+ message := provider.Message{
30+ Role: provider.RoleDeveloper,
31+ Content: []provider.Content{provider.TextBlock{Text: text}},
32+ Time: time.Now(),
33+ Meta: map[string]string{internalContextMarker: "true"},
34+ }
35+
36+ a.mu.Lock()
37+ for i := len(a.messages) - 1; i >= 0; i-- {
38+ if !isInternalContextMessage(a.messages[i]) {
39+ continue
40+ }
41+ if internalContextText(a.messages[i]) == text {
42+ a.mu.Unlock()
43+ return
44+ }
45+ break
46+ }
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)
52+ insertAt := len(a.messages)
53+ if first {
54+ insertAt = 0
55+ }
56+ a.messages = append(a.messages, provider.Message{})
57+ copy(a.messages[insertAt+1:], a.messages[insertAt:])
58+ a.messages[insertAt] = message
59+ a.rev++
60+ onCompacted := a.OnTranscriptCompacted
61+ persisted := append([]provider.Message(nil), a.messages...)
62+ a.mu.Unlock()
63+ if first && onCompacted != nil {
64+ onCompacted(persisted)
65+ return
66+ }
67+ a.fireMessageAppended(message)
68+}
69+
70+func hasInternalContextMessage(messages []provider.Message) bool {
71+ for _, message := range messages {
72+ if isInternalContextMessage(message) {
73+ return true
74+ }
75+ }
76+ return false
77+}
78+
79+func isInternalContextMessage(message provider.Message) bool {
80+ return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true"
81+}
82+
83+func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) {
84+ filtered := make([]provider.Message, 0, len(messages))
85+ var latest *provider.Message
86+ for _, message := range messages {
87+ if isInternalContextMessage(message) {
88+ copy := message
89+ latest = &copy
90+ continue
91+ }
92+ filtered = append(filtered, message)
93+ }
94+ return latest, filtered
95+}
96+
97+func internalContextText(message provider.Message) string {
98+ if len(message.Content) != 1 {
99+ return ""
100+ }
101+ text, _ := message.Content[0].(provider.TextBlock)
102+ return text.Text
103+}
A packages/core/dynamic_context_test.go +46 -0
@@ -0,0 +1,46 @@
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+ opened, messages, err := OpenSession(path)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ defer opened.Close()
43+ if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser {
44+ t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages)
45+ }
46+}
@@ -0,0 +1,46 @@
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+ opened, messages, err := OpenSession(path)
39+ if err != nil {
40+ t.Fatal(err)
41+ }
42+ defer opened.Close()
43+ if len(messages) < 2 || messages[0].Role != provider.RoleDeveloper || messages[1].Role != provider.RoleUser {
44+ t.Fatalf("restored messages = %#v, want developer context followed by accepted user message", messages)
45+ }
46+}
M packages/core/events.go +11 -0
@@ -86,6 +86,17 @@ type EvRequestStarted struct {
8686
8787 func (EvRequestStarted) Type() string { return "request_started" }
8888
89+// EvCacheDiagnostics reports sanitized provider cache-routing state. It never
90+// includes prompt text, durable IDs, request bodies, or credentials.
91+type EvCacheDiagnostics struct {
92+ Eligible bool
93+ Mode string
94+ Transport string
95+ Continuation string
96+}
97+
98+func (EvCacheDiagnostics) Type() string { return "cache_diagnostics" }
99+
89100 // EvRetryScheduled reports a retry delay before its upcoming attempt.
90101 type EvRetryScheduled struct {
91102 Scope RetryScope
@@ -86,6 +86,17 @@ type EvRequestStarted struct {
86 86
87 func (EvRequestStarted) Type() string { return "request_started" }87 func (EvRequestStarted) Type() string { return "request_started" }
88 88
89+// EvCacheDiagnostics reports sanitized provider cache-routing state. It never
90+// includes prompt text, durable IDs, request bodies, or credentials.
91+type EvCacheDiagnostics struct {
92+ Eligible bool
93+ Mode string
94+ Transport string
95+ Continuation string
96+}
97+
98+func (EvCacheDiagnostics) Type() string { return "cache_diagnostics" }
99+
89 // EvRetryScheduled reports a retry delay before its upcoming attempt.100 // EvRetryScheduled reports a retry delay before its upcoming attempt.
90 type EvRetryScheduled struct {101 type EvRetryScheduled struct {
91 Scope RetryScope102 Scope RetryScope
M packages/core/session.go +3 -1
@@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
367367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
368368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
369369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
370+ lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens)
371+ lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens)
370372 lastTurn.CostUSD = last.CostUSD - prev.CostUSD
371373 if lastTurn.CostUSD < 0 {
372374 lastTurn.CostUSD = 0
@@ -1810,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
18101812 if err := json.Unmarshal(rawMessage, &row); err != nil {
18111813 return provider.Message{}, err
18121814 }
1813- 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 {
18141816 return provider.Message{}, fmt.Errorf("message has invalid role %q", row.Role)
18151817 }
18161818 if len(row.Content) == 0 {
@@ -367,6 +367,8 @@ func SessionUsageDetail(path string) (cumulative, lastTurn provider.Usage, err e
367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown367 lastTurn.ReasoningTokensKnown = last.ReasoningTokensKnown
368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)368 lastTurn.CacheReadTokens = nonNegDelta(last.CacheReadTokens, prev.CacheReadTokens)
369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)369 lastTurn.CacheWriteTokens = nonNegDelta(last.CacheWriteTokens, prev.CacheWriteTokens)
370+ lastTurn.CacheMeasuredPromptTokens = nonNegDelta(last.CacheMeasuredPromptTokens, prev.CacheMeasuredPromptTokens)
371+ lastTurn.CacheMeasuredReadTokens = nonNegDelta(last.CacheMeasuredReadTokens, prev.CacheMeasuredReadTokens)
370 lastTurn.CostUSD = last.CostUSD - prev.CostUSD372 lastTurn.CostUSD = last.CostUSD - prev.CostUSD
371 if lastTurn.CostUSD < 0 {373 if lastTurn.CostUSD < 0 {
372 lastTurn.CostUSD = 0374 lastTurn.CostUSD = 0
@@ -1810,7 +1812,7 @@ func hydrateMessageObject(rawMessage []byte) (provider.Message, error) {
1810 if err := json.Unmarshal(rawMessage, &row); err != nil {1812 if err := json.Unmarshal(rawMessage, &row); err != nil {
1811 return provider.Message{}, err1813 return provider.Message{}, err
1812 }1814 }
1813- 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 {
1814 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)
1815 }1817 }
1816 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/core/session_portable.go +6 -2
@@ -491,7 +491,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
491491 return "", err
492492 }
493493 newID := uuid.NewString()
494- name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])
494+ now := time.Now()
495+ timezone, timezoneOffset := localTimeMetadata(now)
496+ name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8])
495497 outPath := filepath.Join(dir, name)
496498 // Build in a same-directory temporary file. Rename is atomic only when
497499 // the temporary and final paths share a directory; closing the file first
@@ -521,8 +523,10 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
521523 CWD: cwd,
522524 Model: parent.Model,
523525 Provider: parent.Provider,
524- Started: time.Now().UTC(),
526+ Started: now.UTC(),
525527 Version: version,
528+ Timezone: timezone,
529+ TimezoneOffset: timezoneOffset,
526530 Parent: parent.ID,
527531 ForkPoint: limit,
528532 HideFromSessions: hideFromSessions,
@@ -491,7 +491,9 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
491 return "", err491 return "", err
492 }492 }
493 newID := uuid.NewString()493 newID := uuid.NewString()
494- name := fmt.Sprintf("%s-%s.jsonl", time.Now().UTC().Format("20060102-150405"), newID[:8])494+ now := time.Now()
495+ timezone, timezoneOffset := localTimeMetadata(now)
496+ name := fmt.Sprintf("%s-%s.jsonl", now.UTC().Format("20060102-150405"), newID[:8])
495 outPath := filepath.Join(dir, name)497 outPath := filepath.Join(dir, name)
496 // Build in a same-directory temporary file. Rename is atomic only when498 // Build in a same-directory temporary file. Rename is atomic only when
497 // the temporary and final paths share a directory; closing the file first499 // the temporary and final paths share a directory; closing the file first
@@ -521,8 +523,10 @@ func writeBranchSession(root, cwd, version string, parent SessionMeta, messages
521 CWD: cwd,523 CWD: cwd,
522 Model: parent.Model,524 Model: parent.Model,
523 Provider: parent.Provider,525 Provider: parent.Provider,
524- Started: time.Now().UTC(),526+ Started: now.UTC(),
525 Version: version,527 Version: version,
528+ Timezone: timezone,
529+ TimezoneOffset: timezoneOffset,
526 Parent: parent.ID,530 Parent: parent.ID,
527 ForkPoint: limit,531 ForkPoint: limit,
528 HideFromSessions: hideFromSessions,532 HideFromSessions: hideFromSessions,
M packages/core/session_usage_test.go +39 -0
@@ -7,6 +7,45 @@ import (
77 "github.com/bnema/zut/packages/provider"
88 )
99
10+func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) {
11+ sess := newUsageTestSession(t)
12+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil {
13+ t.Fatal(err)
14+ }
15+ first := provider.Usage{
16+ InputTokens: 60,
17+ CacheReadTokens: 40,
18+ CacheMeasuredPromptTokens: 100,
19+ CacheMeasuredReadTokens: 40,
20+ }
21+ if err := sess.AppendUsage(first, first); err != nil {
22+ t.Fatal(err)
23+ }
24+ second := provider.Usage{
25+ InputTokens: 80,
26+ CacheReadTokens: 70,
27+ CacheMeasuredPromptTokens: 150,
28+ CacheMeasuredReadTokens: 70,
29+ }
30+ if err := sess.AppendUsage(provider.Usage{}, second); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := sess.Close(); err != nil {
34+ t.Fatal(err)
35+ }
36+
37+ cumulative, last, err := SessionUsageDetail(sess.Path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 {
42+ t.Fatalf("cumulative measured cache usage = %+v", cumulative)
43+ }
44+ if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 {
45+ t.Fatalf("last measured cache usage = %+v", last)
46+ }
47+}
48+
1049 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
1150 t.Run("compaction is latest row", func(t *testing.T) {
1251 sess := newUsageTestSession(t)
@@ -7,6 +7,45 @@ import (
7 "github.com/bnema/zut/packages/provider"7 "github.com/bnema/zut/packages/provider"
8 )8 )
9 9
10+func TestSessionUsageDetailDeltasMeasuredCacheUsage(t *testing.T) {
11+ sess := newUsageTestSession(t)
12+ if err := sess.AppendMessage(provider.Message{Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "prompt"}}}); err != nil {
13+ t.Fatal(err)
14+ }
15+ first := provider.Usage{
16+ InputTokens: 60,
17+ CacheReadTokens: 40,
18+ CacheMeasuredPromptTokens: 100,
19+ CacheMeasuredReadTokens: 40,
20+ }
21+ if err := sess.AppendUsage(first, first); err != nil {
22+ t.Fatal(err)
23+ }
24+ second := provider.Usage{
25+ InputTokens: 80,
26+ CacheReadTokens: 70,
27+ CacheMeasuredPromptTokens: 150,
28+ CacheMeasuredReadTokens: 70,
29+ }
30+ if err := sess.AppendUsage(provider.Usage{}, second); err != nil {
31+ t.Fatal(err)
32+ }
33+ if err := sess.Close(); err != nil {
34+ t.Fatal(err)
35+ }
36+
37+ cumulative, last, err := SessionUsageDetail(sess.Path)
38+ if err != nil {
39+ t.Fatal(err)
40+ }
41+ if cumulative.CacheMeasuredPromptTokens != 150 || cumulative.CacheMeasuredReadTokens != 70 {
42+ t.Fatalf("cumulative measured cache usage = %+v", cumulative)
43+ }
44+ if last.CacheMeasuredPromptTokens != 50 || last.CacheMeasuredReadTokens != 30 {
45+ t.Fatalf("last measured cache usage = %+v", last)
46+ }
47+}
48+
10 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {49 func TestSessionUsageDetailResetsContextAfterCompaction(t *testing.T) {
11 t.Run("compaction is latest row", func(t *testing.T) {50 t.Run("compaction is latest row", func(t *testing.T) {
12 sess := newUsageTestSession(t)51 sess := newUsageTestSession(t)
M packages/core/time_context.go +12 -1
@@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string
6565 zone: timezone,
6666 offset: offset,
6767 }
68+ a.hasSessionTimeContext = true
6869 a.mu.Unlock()
6970 }
7071
7172 func (a *Agent) providerTimeContext() agentTimeContext {
7273 a.mu.Lock()
7374 defer a.mu.Unlock()
75+ if !a.hasSessionTimeContext {
76+ return agentTimeContext{}
77+ }
7478 return a.timeContext
7579 }
7680
7781 func (c agentTimeContext) systemText() string {
82+ return c.developerText()
83+}
84+
85+// developerText returns host context projected after stable instructions.
86+// Minute precision is sufficient for coordination and avoids generating a
87+// unique timestamp solely to distinguish otherwise-equal agents.
88+func (c agentTimeContext) developerText() string {
7889 if c.started.IsZero() {
7990 return ""
8091 }
@@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string {
91102 _, seconds := c.started.In(location).Zone()
92103 offset = formatUTCOffset(seconds)
93104 }
94- return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)
105+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset)
95106 }
96107
97108 func formatUTCOffset(seconds int) string {
@@ -65,16 +65,27 @@ func (a *Agent) SetSessionTimeContext(started time.Time, timezone, offset string
65 zone: timezone,65 zone: timezone,
66 offset: offset,66 offset: offset,
67 }67 }
68+ a.hasSessionTimeContext = true
68 a.mu.Unlock()69 a.mu.Unlock()
69 }70 }
70 71
71 func (a *Agent) providerTimeContext() agentTimeContext {72 func (a *Agent) providerTimeContext() agentTimeContext {
72 a.mu.Lock()73 a.mu.Lock()
73 defer a.mu.Unlock()74 defer a.mu.Unlock()
75+ if !a.hasSessionTimeContext {
76+ return agentTimeContext{}
77+ }
74 return a.timeContext78 return a.timeContext
75 }79 }
76 80
77 func (c agentTimeContext) systemText() string {81 func (c agentTimeContext) systemText() string {
82+ return c.developerText()
83+}
84+
85+// developerText returns host context projected after stable instructions.
86+// Minute precision is sufficient for coordination and avoids generating a
87+// unique timestamp solely to distinguish otherwise-equal agents.
88+func (c agentTimeContext) developerText() string {
78 if c.started.IsZero() {89 if c.started.IsZero() {
79 return ""90 return ""
80 }91 }
@@ -91,7 +102,7 @@ func (c agentTimeContext) systemText() string {
91 _, seconds := c.started.In(location).Zone()102 _, seconds := c.started.In(location).Zone()
92 offset = formatUTCOffset(seconds)103 offset = formatUTCOffset(seconds)
93 }104 }
94- return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format(time.RFC3339), zone, offset)105+ return fmt.Sprintf("[Session time context]\nsession_started: %s\nlocal_timezone: %s (UTC%s)", c.started.In(location).Format("2006-01-02 15:04"), zone, offset)
95 }106 }
96 107
97 func formatUTCOffset(seconds int) string {108 func formatUTCOffset(seconds int) string {
M packages/provider/amazon_bedrock.go +7 -3
@@ -333,8 +333,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
333333 reasoning := ClampReasoningForModel(model, req.Reasoning)
334334 adaptive := usesAdaptiveThinking(model)
335335
336- if system := req.SystemPrompt(); system != "" {
337- sysBlock := map[string]interface{}{"text": system}
336+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
337+ if stableSystem != "" {
338+ sysBlock := map[string]interface{}{"text": stableSystem}
338339 if caching {
339340 // Append cachePoint after the system text so the stable system
340341 // prompt is cached as the first breakpoint.
@@ -343,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
343344 out.System = []map[string]interface{}{sysBlock}
344345 }
345346 }
347+ if dynamicSystem != "" {
348+ out.System = append(out.System, map[string]interface{}{"text": dynamicSystem})
349+ }
346350 if !adaptive {
347351 out.InferenceConfig.Temperature = req.Temperature
348352 }
@@ -356,7 +360,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
356360 if out.InferenceConfig.MaxTokens == 0 {
357361 out.InferenceConfig.MaxTokens = 4096
358362 }
359- for _, m := range normalizeBedrockToolResults(req.Messages) {
363+ for _, m := range normalizeBedrockToolResults(messages) {
360364 role := string(m.Role)
361365 if role == "tool" {
362366 role = "user"
@@ -333,8 +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- if system := req.SystemPrompt(); system != "" {336+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
337- sysBlock := map[string]interface{}{"text": system}337+ if stableSystem != "" {
338+ sysBlock := map[string]interface{}{"text": stableSystem}
338 if caching {339 if caching {
339 // Append cachePoint after the system text so the stable system340 // Append cachePoint after the system text so the stable system
340 // prompt is cached as the first breakpoint.341 // prompt is cached as the first breakpoint.
@@ -343,6 +344,9 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
343 out.System = []map[string]interface{}{sysBlock}344 out.System = []map[string]interface{}{sysBlock}
344 }345 }
345 }346 }
347+ if dynamicSystem != "" {
348+ out.System = append(out.System, map[string]interface{}{"text": dynamicSystem})
349+ }
346 if !adaptive {350 if !adaptive {
347 out.InferenceConfig.Temperature = req.Temperature351 out.InferenceConfig.Temperature = req.Temperature
348 }352 }
@@ -356,7 +360,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
356 if out.InferenceConfig.MaxTokens == 0 {360 if out.InferenceConfig.MaxTokens == 0 {
357 out.InferenceConfig.MaxTokens = 4096361 out.InferenceConfig.MaxTokens = 4096
358 }362 }
359- for _, m := range normalizeBedrockToolResults(req.Messages) {363+ for _, m := range normalizeBedrockToolResults(messages) {
360 role := string(m.Role)364 role := string(m.Role)
361 if role == "tool" {365 if role == "tool" {
362 role = "user"366 role = "user"
M packages/provider/anthropic.go +11 -8
@@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246246 out.Temperature = req.Temperature
247247 }
248248
249- system := req.SystemPrompt()
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 {
@@ -318,7 +321,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
318321 }
319322 }
320323
321- for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
324+ for _, t := range activeToolDefinitions(req.Tools, messages) {
322325 name := t.Name
323326 if c.oauthTok != "" {
324327 name = toClaudeCodeToolName(name)
@@ -353,8 +356,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
353356 // emitting them separately keeps each message bit-stable across
354357 // turns, so the cache prefix matches for the entire history up
355358 // to the newest block.
356- req.Messages = RepairOrphanedToolResults(req.Messages)
357- for _, msg := range req.Messages {
359+ messages = RepairOrphanedToolResults(messages)
360+ for _, msg := range messages {
358361 renameTools := c.oauthTok != ""
359362 switch msg.Role {
360363 case RoleUser:
@@ -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 := req.SystemPrompt()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 {
@@ -318,7 +321,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
318 }321 }
319 }322 }
320 323
321- for _, t := range activeToolDefinitions(req.Tools, req.Messages) {324+ for _, t := range activeToolDefinitions(req.Tools, messages) {
322 name := t.Name325 name := t.Name
323 if c.oauthTok != "" {326 if c.oauthTok != "" {
324 name = toClaudeCodeToolName(name)327 name = toClaudeCodeToolName(name)
@@ -353,8 +356,8 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
353 // emitting them separately keeps each message bit-stable across356 // emitting them separately keeps each message bit-stable across
354 // turns, so the cache prefix matches for the entire history up357 // turns, so the cache prefix matches for the entire history up
355 // to the newest block.358 // to the newest block.
356- req.Messages = RepairOrphanedToolResults(req.Messages)359+ messages = RepairOrphanedToolResults(messages)
357- for _, msg := range req.Messages {360+ for _, msg := range messages {
358 renameTools := c.oauthTok != ""361 renameTools := c.oauthTok != ""
359 switch msg.Role {362 switch msg.Role {
360 case RoleUser:363 case RoleUser:
A packages/provider/developer_context.go +28 -0
@@ -0,0 +1,28 @@
1+package provider
2+
3+import "strings"
4+
5+// systemWithDeveloperContext projects host-authored developer messages onto
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+ }
16+ for _, message := range req.Messages {
17+ if message.Role != RoleDeveloper {
18+ messages = append(messages, message)
19+ continue
20+ }
21+ for _, block := range message.Content {
22+ if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" {
23+ context = append(context, text.Text)
24+ }
25+ }
26+ }
27+ return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages
28+}
@@ -0,0 +1,28 @@
1+package provider
2+
3+import "strings"
4+
5+// systemWithDeveloperContext projects host-authored developer messages onto
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+ }
16+ for _, message := range req.Messages {
17+ if message.Role != RoleDeveloper {
18+ messages = append(messages, message)
19+ continue
20+ }
21+ for _, block := range message.Content {
22+ if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" {
23+ context = append(context, text.Text)
24+ }
25+ }
26+ }
27+ return strings.TrimSpace(req.System), strings.Join(context, "\n\n"), messages
28+}
A packages/provider/developer_context_test.go +57 -0
@@ -0,0 +1,57 @@
1+package provider
2+
3+import "testing"
4+
5+func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) {
6+ stable, dynamic, messages := systemWithDeveloperContext(Request{
7+ System: "stable instructions",
8+ SystemContext: "legacy host context",
9+ Messages: []Message{
10+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
11+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
12+ },
13+ })
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)
19+ }
20+ if len(messages) != 1 || messages[0].Role != RoleUser {
21+ t.Fatalf("messages = %#v, want only the user task", messages)
22+ }
23+}
24+
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",
46+ Messages: []Message{
47+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
48+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
49+ },
50+ })
51+ if err != nil {
52+ t.Fatal(err)
53+ }
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)
56+ }
57+}
@@ -0,0 +1,57 @@
1+package provider
2+
3+import "testing"
4+
5+func TestSystemWithDeveloperContextSeparatesStableAndDynamicText(t *testing.T) {
6+ stable, dynamic, messages := systemWithDeveloperContext(Request{
7+ System: "stable instructions",
8+ SystemContext: "legacy host context",
9+ Messages: []Message{
10+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
11+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
12+ },
13+ })
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)
19+ }
20+ if len(messages) != 1 || messages[0].Role != RoleUser {
21+ t.Fatalf("messages = %#v, want only the user task", messages)
22+ }
23+}
24+
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",
46+ Messages: []Message{
47+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
48+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
49+ },
50+ })
51+ if err != nil {
52+ t.Fatal(err)
53+ }
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)
56+ }
57+}
M packages/provider/gemini.go +6 -4
@@ -142,17 +142,19 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
142142 }
143143
144144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
146+ system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n"))
145147 out := &gemRequest{}
146148
147149 // System prompt → systemInstruction.parts[0].text.
148- if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {
150+ if system != "" {
149151 out.SystemInstruction = &gemSystemInstruction{
150152 Parts: []gemPart{{Text: system}},
151153 }
152154 }
153155
154156 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
155- activeTools := activeToolDefinitions(req.Tools, req.Messages)
157+ activeTools := activeToolDefinitions(req.Tools, messages)
156158
157159 // Convert tool defs. Gemini image-generation models reject function
158160 // declarations with "Function calling is not enabled for this model";
@@ -190,9 +192,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
190192 // Convert messages. When function calling is disabled for the target
191193 // model, also remove historical functionCall/functionResponse parts;
192194 // image models should receive only text/image content.
193- msgs := req.Messages
195+ msgs := messages
194196 if functionsEnabled {
195- msgs = RepairOrphanedToolResults(req.Messages)
197+ msgs = RepairOrphanedToolResults(messages)
196198 }
197199 for _, msg := range msgs {
198200 switch msg.Role {
@@ -142,17 +142,19 @@ 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+ stableSystem, dynamicSystem, messages := systemWithDeveloperContext(req)
146+ system := strings.TrimSpace(strings.Join([]string{stableSystem, dynamicSystem}, "\n\n"))
145 out := &gemRequest{}147 out := &gemRequest{}
146 148
147 // System prompt → systemInstruction.parts[0].text.149 // System prompt → systemInstruction.parts[0].text.
148- if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {150+ if system != "" {
149 out.SystemInstruction = &gemSystemInstruction{151 out.SystemInstruction = &gemSystemInstruction{
150 Parts: []gemPart{{Text: system}},152 Parts: []gemPart{{Text: system}},
151 }153 }
152 }154 }
153 155
154 functionsEnabled := geminiSupportsFunctionCalling(m.ID)156 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
155- activeTools := activeToolDefinitions(req.Tools, req.Messages)157+ activeTools := activeToolDefinitions(req.Tools, messages)
156 158
157 // Convert tool defs. Gemini image-generation models reject function159 // Convert tool defs. Gemini image-generation models reject function
158 // declarations with "Function calling is not enabled for this model";160 // declarations with "Function calling is not enabled for this model";
@@ -190,9 +192,9 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
190 // Convert messages. When function calling is disabled for the target192 // Convert messages. When function calling is disabled for the target
191 // model, also remove historical functionCall/functionResponse parts;193 // model, also remove historical functionCall/functionResponse parts;
192 // image models should receive only text/image content.194 // image models should receive only text/image content.
193- msgs := req.Messages195+ msgs := messages
194 if functionsEnabled {196 if functionsEnabled {
195- msgs = RepairOrphanedToolResults(req.Messages)197+ msgs = RepairOrphanedToolResults(messages)
196 }198 }
197 for _, msg := range msgs {199 for _, msg := range msgs {
198 switch msg.Role {200 switch msg.Role {
M packages/provider/openai.go +7 -11
@@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
299299 req.Messages = RepairOrphanedToolResults(req.Messages)
300300 for _, msg := range req.Messages {
301301 switch msg.Role {
302+ case RoleDeveloper:
303+ content := buildOAIUserContent(msg.Content, textOnly)
304+ out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content})
302305 case RoleUser:
303306 content := buildOAIUserContent(msg.Content, textOnly)
304307 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
@@ -656,11 +659,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
656659 FinishReason string `json:"finish_reason"`
657660 } `json:"choices"`
658661 Usage *struct {
659- PromptTokens int `json:"prompt_tokens"`
660- CompletionTokens int `json:"completion_tokens"`
661- PromptTokensDetails struct {
662- CachedTokens int `json:"cached_tokens"`
663- } `json:"prompt_tokens_details"`
662+ PromptTokens int `json:"prompt_tokens"`
663+ CompletionTokens int `json:"completion_tokens"`
664+ PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"`
664665 CompletionTokensDetails *struct {
665666 ReasoningTokens int `json:"reasoning_tokens"`
666667 } `json:"completion_tokens_details"`
@@ -680,12 +681,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
680681 return
681682 }
682683 if chunk.Usage != nil {
683- usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens
684- if usage.InputTokens < 0 {
685- usage.InputTokens = chunk.Usage.PromptTokens
686- }
687- usage.OutputTokens = chunk.Usage.CompletionTokens
688- usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
684+ usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails)
689685 if details := chunk.Usage.CompletionTokensDetails; details != nil {
690686 usage.ReasoningTokens = details.ReasoningTokens
691687 usage.ReasoningTokensKnown = true
@@ -299,6 +299,9 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
299 req.Messages = RepairOrphanedToolResults(req.Messages)299 req.Messages = RepairOrphanedToolResults(req.Messages)
300 for _, msg := range req.Messages {300 for _, msg := range req.Messages {
301 switch msg.Role {301 switch msg.Role {
302+ case RoleDeveloper:
303+ content := buildOAIUserContent(msg.Content, textOnly)
304+ out.Messages = append(out.Messages, oaiMessage{Role: "developer", Content: content})
302 case RoleUser:305 case RoleUser:
303 content := buildOAIUserContent(msg.Content, textOnly)306 content := buildOAIUserContent(msg.Content, textOnly)
304 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})307 out.Messages = append(out.Messages, oaiMessage{Role: "user", Content: content})
@@ -656,11 +659,9 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
656 FinishReason string `json:"finish_reason"`659 FinishReason string `json:"finish_reason"`
657 } `json:"choices"`660 } `json:"choices"`
658 Usage *struct {661 Usage *struct {
659- PromptTokens int `json:"prompt_tokens"`662+ PromptTokens int `json:"prompt_tokens"`
660- CompletionTokens int `json:"completion_tokens"`663+ CompletionTokens int `json:"completion_tokens"`
661- PromptTokensDetails struct {664+ PromptTokensDetails *openAIInputTokensDetails `json:"prompt_tokens_details"`
662- CachedTokens int `json:"cached_tokens"`
663- } `json:"prompt_tokens_details"`
664 CompletionTokensDetails *struct {665 CompletionTokensDetails *struct {
665 ReasoningTokens int `json:"reasoning_tokens"`666 ReasoningTokens int `json:"reasoning_tokens"`
666 } `json:"completion_tokens_details"`667 } `json:"completion_tokens_details"`
@@ -680,12 +681,7 @@ func (c *openaiClient) runStream(ctx context.Context, resp *http.Response, req R
680 return681 return
681 }682 }
682 if chunk.Usage != nil {683 if chunk.Usage != nil {
683- usage.InputTokens = chunk.Usage.PromptTokens - chunk.Usage.PromptTokensDetails.CachedTokens684+ usage = normalizeOpenAIUsage(chunk.Usage.PromptTokens, chunk.Usage.CompletionTokens, chunk.Usage.PromptTokensDetails)
684- if usage.InputTokens < 0 {
685- usage.InputTokens = chunk.Usage.PromptTokens
686- }
687- usage.OutputTokens = chunk.Usage.CompletionTokens
688- usage.CacheReadTokens = chunk.Usage.PromptTokensDetails.CachedTokens
689 if details := chunk.Usage.CompletionTokensDetails; details != nil {685 if details := chunk.Usage.CompletionTokensDetails; details != nil {
690 usage.ReasoningTokens = details.ReasoningTokens686 usage.ReasoningTokens = details.ReasoningTokens
691 usage.ReasoningTokensKnown = true687 usage.ReasoningTokensKnown = true
M packages/provider/openai_codex.go +102 -38
@@ -51,8 +51,11 @@ type codexClient struct {
5151 // responsesCapabilities records extensions verified for the exact provider
5252 // client, rather than assuming every Responses-shaped endpoint accepts them.
5353 type responsesCapabilities struct {
54- StablePromptCacheKey bool
55- SessionHeader bool
54+ StablePromptCacheKey bool
55+ ExplicitPromptCacheBreakpoint bool
56+ SessionHeader bool
57+ ThreadHeader bool
58+ ClientRequestHeader bool
5659 }
5760
5861 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -78,6 +81,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
7881 capabilities: responsesCapabilities{
7982 StablePromptCacheKey: true,
8083 SessionHeader: true,
84+ ThreadHeader: true,
85+ ClientRequestHeader: true,
8186 },
8287 http: &http.Client{Timeout: 0},
8388 }
@@ -88,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" }
8893 // ---- Responses API wire types (subset needed for zut's surface) ----
8994
9095 type codexInputText struct {
91- Type string `json:"type"` // "input_text"
92- Text string `json:"text"`
96+ Type string `json:"type"` // "input_text"
97+ Text string `json:"text"`
98+ PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
9399 }
94100
95101 type codexInputImage struct {
@@ -104,6 +110,14 @@ type codexOutputText struct {
104110 Annotations []any `json:"annotations"`
105111 }
106112
113+type codexPromptCacheOptions struct {
114+ Mode string `json:"mode"`
115+}
116+
117+type codexPromptCacheBreakpoint struct {
118+ Mode string `json:"mode"`
119+}
120+
107121 type codexInputMessage struct {
108122 Type string `json:"type,omitempty"` // "message" (optional for input)
109123 Role string `json:"role"`
@@ -162,19 +176,20 @@ type codexReasoningConfig struct {
162176 }
163177
164178 type codexRequest struct {
165- Model string `json:"model"`
166- Store bool `json:"store"`
167- Stream bool `json:"stream,omitempty"`
168- Instructions string `json:"instructions,omitempty"`
169- Input []any `json:"input"`
170- Tools []codexTool `json:"tools,omitempty"`
171- ToolChoice string `json:"tool_choice,omitempty"`
172- ParallelToolCalls bool `json:"parallel_tool_calls"`
173- Include []string `json:"include,omitempty"`
174- Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
175- PromptCacheKey string `json:"prompt_cache_key,omitempty"`
176- ServiceTier string `json:"service_tier,omitempty"`
177- PreviousResponseID string `json:"previous_response_id,omitempty"`
179+ Model string `json:"model"`
180+ Store bool `json:"store"`
181+ Stream bool `json:"stream,omitempty"`
182+ Instructions string `json:"instructions,omitempty"`
183+ Input []any `json:"input"`
184+ Tools []codexTool `json:"tools,omitempty"`
185+ ToolChoice string `json:"tool_choice,omitempty"`
186+ ParallelToolCalls bool `json:"parallel_tool_calls"`
187+ Include []string `json:"include,omitempty"`
188+ Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
189+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
190+ PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"`
191+ ServiceTier string `json:"service_tier,omitempty"`
192+ PreviousResponseID string `json:"previous_response_id,omitempty"`
178193 }
179194
180195 // ---- Request building ----
@@ -191,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) {
191206 return FindModel("openai", id)
192207 }
193208
209+func isGPT56Model(model string) bool {
210+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-")
211+}
212+
194213 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195214 providerName := c.providerName
196215 if providerName == "" {
@@ -216,6 +235,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
216235 if req.FastMode {
217236 body.ServiceTier = fastModeServiceTier
218237 }
238+ if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) {
239+ // Keep implicit mode so OpenAI can retain later conversation
240+ // checkpoints as well as this stable instruction/tool boundary.
241+ body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}
242+ body.Input = append(body.Input, codexInputMessage{
243+ Role: "developer",
244+ Content: []any{codexInputText{
245+ Type: "input_text",
246+ Text: "[Stable prompt boundary]",
247+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"},
248+ }},
249+ })
250+ }
219251 if m.Reasoning {
220252 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
221253 if hasReasoningLevelOverride(m, req.Reasoning) {
@@ -246,7 +278,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
246278 req.Messages = RepairOrphanedToolResults(req.Messages)
247279 for _, msg := range req.Messages {
248280 switch msg.Role {
249- case RoleUser:
281+ case RoleDeveloper, RoleUser:
250282 content := []any{}
251283 for _, c := range msg.Content {
252284 switch v := c.(type) {
@@ -262,7 +294,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
262294 if len(content) == 0 {
263295 continue
264296 }
265- body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
297+ role := "user"
298+ if msg.Role == RoleDeveloper {
299+ role = "developer"
300+ }
301+ body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
266302 case RoleAssistant:
267303 // Emit one output_message per text block, one function_call per
268304 // tool call, and one reasoning item per ReasoningBlock,
@@ -363,6 +399,29 @@ func splitCallID(id string) (string, string) {
363399 return id, ""
364400 }
365401
402+const minOpenAIPromptCacheTokens = 1024
403+
404+func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics {
405+ diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation}
406+ if wire == nil || wire.PromptCacheOptions == nil {
407+ return diagnostics
408+ }
409+ diagnostics.Mode = wire.PromptCacheOptions.Mode
410+ diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens
411+ return diagnostics
412+}
413+
414+// estimatedOpenAIPromptTokens supplies a deliberately conservative local
415+// eligibility signal. OpenAI makes the final tokenization and cache decision;
416+// this estimate never leaves the process and is not persisted in diagnostics.
417+func estimatedOpenAIPromptTokens(wire *codexRequest) int {
418+ encoded, err := json.Marshal(wire)
419+ if err != nil {
420+ return 0
421+ }
422+ return len(encoded) / 4
423+}
424+
366425 func usesCodexCLIRouting(model string) bool {
367426 switch model {
368427 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
@@ -379,13 +438,15 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
379438 if err != nil {
380439 return nil, err
381440 }
441+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full"))
382442 if c.modelName != nil {
383443 wire.Model = c.modelName(wire.Model)
384444 }
385445 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
386- logicalSessionID := strings.TrimSpace(req.Context.SessionID)
387- if c.capabilities.StablePromptCacheKey && logicalSessionID != "" {
388- wire.PromptCacheKey = logicalSessionID
446+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
447+ threadID := strings.TrimSpace(req.Context.ThreadID)
448+ if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449+ wire.PromptCacheKey = cacheSessionID
389450 }
390451 body, err := json.Marshal(wire)
391452 if err != nil {
@@ -408,8 +469,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
408469 // identities are load-shed with "servers are currently
409470 // overloaded" errors even when capacity is fine.
410471 httpReq.Header.Set("originator", "codex_cli_rs")
411- if c.capabilities.SessionHeader && logicalSessionID != "" {
412- httpReq.Header.Set("session-id", logicalSessionID)
472+ if c.capabilities.SessionHeader && cacheSessionID != "" {
473+ httpReq.Header.Set("session-id", cacheSessionID)
474+ }
475+ if c.capabilities.ThreadHeader && threadID != "" {
476+ httpReq.Header.Set("thread-id", threadID)
477+ }
478+ if c.capabilities.ClientRequestHeader && threadID != "" {
479+ httpReq.Header.Set("x-client-request-id", threadID)
413480 }
414481 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
415482 } else {
@@ -450,10 +517,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
450517 // event vocabulary, so keeping the state machine here prevents the transports
451518 // from drifting in tool-call, reasoning, and usage handling.
452519 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {
453- c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed)
520+ var observe func(string, Message)
521+ if completed != nil {
522+ observe = func(responseID string, _ Message) { completed(responseID) }
523+ }
524+ c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe)
454525 }
455526
456-func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) {
527+func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) {
457528 model, _ := c.findModel(req.Model)
458529 providerName := c.providerName
459530 if providerName == "" {
@@ -659,11 +730,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
659730 Response struct {
660731 ID string `json:"id"`
661732 Usage struct {
662- InputTokens int `json:"input_tokens"`
663- OutputTokens int `json:"output_tokens"`
664- InputTokensDetails struct {
665- CachedTokens int `json:"cached_tokens"`
666- } `json:"input_tokens_details"`
733+ InputTokens int `json:"input_tokens"`
734+ OutputTokens int `json:"output_tokens"`
735+ InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"`
667736 OutputTokensDetails *struct {
668737 ReasoningTokens int `json:"reasoning_tokens"`
669738 } `json:"output_tokens_details"`
@@ -672,12 +741,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
672741 } `json:"response"`
673742 }
674743 _ = json.Unmarshal([]byte(ev.Data), &p)
675- usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens
676- if usage.InputTokens < 0 {
677- usage.InputTokens = p.Response.Usage.InputTokens
678- }
679- usage.OutputTokens = p.Response.Usage.OutputTokens
680- usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
744+ usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
681745 if details := p.Response.Usage.OutputTokensDetails; details != nil {
682746 usage.ReasoningTokens = details.ReasoningTokens
683747 usage.ReasoningTokensKnown = true
@@ -696,7 +760,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
696760 stop = StopEnd
697761 }
698762 if completed != nil && p.Response.ID != "" {
699- completed(p.Response.ID)
763+ completed(p.Response.ID, assemble())
700764 }
701765 sendDone()
702766 return
@@ -51,8 +51,11 @@ type codexClient struct {
51 // responsesCapabilities records extensions verified for the exact provider51 // responsesCapabilities records extensions verified for the exact provider
52 // client, rather than assuming every Responses-shaped endpoint accepts them.52 // client, rather than assuming every Responses-shaped endpoint accepts them.
53 type responsesCapabilities struct {53 type responsesCapabilities struct {
54- StablePromptCacheKey bool54+ StablePromptCacheKey bool
55- SessionHeader bool55+ ExplicitPromptCacheBreakpoint bool
56+ SessionHeader bool
57+ ThreadHeader bool
58+ ClientRequestHeader bool
56 }59 }
57 60
58 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint61 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -78,6 +81,8 @@ func NewOpenAICodex(token, accountID, baseURL string) Client {
78 capabilities: responsesCapabilities{81 capabilities: responsesCapabilities{
79 StablePromptCacheKey: true,82 StablePromptCacheKey: true,
80 SessionHeader: true,83 SessionHeader: true,
84+ ThreadHeader: true,
85+ ClientRequestHeader: true,
81 },86 },
82 http: &http.Client{Timeout: 0},87 http: &http.Client{Timeout: 0},
83 }88 }
@@ -88,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" }
88 // ---- Responses API wire types (subset needed for zut's surface) ----93 // ---- Responses API wire types (subset needed for zut's surface) ----
89 94
90 type codexInputText struct {95 type codexInputText struct {
91- Type string `json:"type"` // "input_text"96+ Type string `json:"type"` // "input_text"
92- Text string `json:"text"`97+ Text string `json:"text"`
98+ PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
93 }99 }
94 100
95 type codexInputImage struct {101 type codexInputImage struct {
@@ -104,6 +110,14 @@ type codexOutputText struct {
104 Annotations []any `json:"annotations"`110 Annotations []any `json:"annotations"`
105 }111 }
106 112
113+type codexPromptCacheOptions struct {
114+ Mode string `json:"mode"`
115+}
116+
117+type codexPromptCacheBreakpoint struct {
118+ Mode string `json:"mode"`
119+}
120+
107 type codexInputMessage struct {121 type codexInputMessage struct {
108 Type string `json:"type,omitempty"` // "message" (optional for input)122 Type string `json:"type,omitempty"` // "message" (optional for input)
109 Role string `json:"role"`123 Role string `json:"role"`
@@ -162,19 +176,20 @@ type codexReasoningConfig struct {
162 }176 }
163 177
164 type codexRequest struct {178 type codexRequest struct {
165- Model string `json:"model"`179+ Model string `json:"model"`
166- Store bool `json:"store"`180+ Store bool `json:"store"`
167- Stream bool `json:"stream,omitempty"`181+ Stream bool `json:"stream,omitempty"`
168- Instructions string `json:"instructions,omitempty"`182+ Instructions string `json:"instructions,omitempty"`
169- Input []any `json:"input"`183+ Input []any `json:"input"`
170- Tools []codexTool `json:"tools,omitempty"`184+ Tools []codexTool `json:"tools,omitempty"`
171- ToolChoice string `json:"tool_choice,omitempty"`185+ ToolChoice string `json:"tool_choice,omitempty"`
172- ParallelToolCalls bool `json:"parallel_tool_calls"`186+ ParallelToolCalls bool `json:"parallel_tool_calls"`
173- Include []string `json:"include,omitempty"`187+ Include []string `json:"include,omitempty"`
174- Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`188+ Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
175- PromptCacheKey string `json:"prompt_cache_key,omitempty"`189+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
176- ServiceTier string `json:"service_tier,omitempty"`190+ PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"`
177- PreviousResponseID string `json:"previous_response_id,omitempty"`191+ ServiceTier string `json:"service_tier,omitempty"`
192+ PreviousResponseID string `json:"previous_response_id,omitempty"`
178 }193 }
179 194
180 // ---- Request building ----195 // ---- Request building ----
@@ -191,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) {
191 return FindModel("openai", id)206 return FindModel("openai", id)
192 }207 }
193 208
209+func isGPT56Model(model string) bool {
210+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-")
211+}
212+
194 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {213 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
195 providerName := c.providerName214 providerName := c.providerName
196 if providerName == "" {215 if providerName == "" {
@@ -216,6 +235,19 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
216 if req.FastMode {235 if req.FastMode {
217 body.ServiceTier = fastModeServiceTier236 body.ServiceTier = fastModeServiceTier
218 }237 }
238+ if c.capabilities.ExplicitPromptCacheBreakpoint && isGPT56Model(req.Model) {
239+ // Keep implicit mode so OpenAI can retain later conversation
240+ // checkpoints as well as this stable instruction/tool boundary.
241+ body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}
242+ body.Input = append(body.Input, codexInputMessage{
243+ Role: "developer",
244+ Content: []any{codexInputText{
245+ Type: "input_text",
246+ Text: "[Stable prompt boundary]",
247+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"},
248+ }},
249+ })
250+ }
219 if m.Reasoning {251 if m.Reasoning {
220 effort := OpenAICodexReasoningEffort(reasoning, req.Model)252 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
221 if hasReasoningLevelOverride(m, req.Reasoning) {253 if hasReasoningLevelOverride(m, req.Reasoning) {
@@ -246,7 +278,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
246 req.Messages = RepairOrphanedToolResults(req.Messages)278 req.Messages = RepairOrphanedToolResults(req.Messages)
247 for _, msg := range req.Messages {279 for _, msg := range req.Messages {
248 switch msg.Role {280 switch msg.Role {
249- case RoleUser:281+ case RoleDeveloper, RoleUser:
250 content := []any{}282 content := []any{}
251 for _, c := range msg.Content {283 for _, c := range msg.Content {
252 switch v := c.(type) {284 switch v := c.(type) {
@@ -262,7 +294,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
262 if len(content) == 0 {294 if len(content) == 0 {
263 continue295 continue
264 }296 }
265- body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})297+ role := "user"
298+ if msg.Role == RoleDeveloper {
299+ role = "developer"
300+ }
301+ body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
266 case RoleAssistant:302 case RoleAssistant:
267 // Emit one output_message per text block, one function_call per303 // Emit one output_message per text block, one function_call per
268 // tool call, and one reasoning item per ReasoningBlock,304 // tool call, and one reasoning item per ReasoningBlock,
@@ -363,6 +399,29 @@ func splitCallID(id string) (string, string) {
363 return id, ""399 return id, ""
364 }400 }
365 401
402+const minOpenAIPromptCacheTokens = 1024
403+
404+func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics {
405+ diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation}
406+ if wire == nil || wire.PromptCacheOptions == nil {
407+ return diagnostics
408+ }
409+ diagnostics.Mode = wire.PromptCacheOptions.Mode
410+ diagnostics.Eligible = estimatedOpenAIPromptTokens(wire) >= minOpenAIPromptCacheTokens
411+ return diagnostics
412+}
413+
414+// estimatedOpenAIPromptTokens supplies a deliberately conservative local
415+// eligibility signal. OpenAI makes the final tokenization and cache decision;
416+// this estimate never leaves the process and is not persisted in diagnostics.
417+func estimatedOpenAIPromptTokens(wire *codexRequest) int {
418+ encoded, err := json.Marshal(wire)
419+ if err != nil {
420+ return 0
421+ }
422+ return len(encoded) / 4
423+}
424+
366 func usesCodexCLIRouting(model string) bool {425 func usesCodexCLIRouting(model string) bool {
367 switch model {426 switch model {
368 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":427 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
@@ -379,13 +438,15 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
379 if err != nil {438 if err != nil {
380 return nil, err439 return nil, err
381 }440 }
441+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full"))
382 if c.modelName != nil {442 if c.modelName != nil {
383 wire.Model = c.modelName(wire.Model)443 wire.Model = c.modelName(wire.Model)
384 }444 }
385 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))445 useCodexCLIRouting := !c.disableCLIRouting && (c.cliRoutingAll || usesCodexCLIRouting(wire.Model))
386- logicalSessionID := strings.TrimSpace(req.Context.SessionID)446+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
387- if c.capabilities.StablePromptCacheKey && logicalSessionID != "" {447+ threadID := strings.TrimSpace(req.Context.ThreadID)
388- wire.PromptCacheKey = logicalSessionID448+ if c.capabilities.StablePromptCacheKey && cacheSessionID != "" {
449+ wire.PromptCacheKey = cacheSessionID
389 }450 }
390 body, err := json.Marshal(wire)451 body, err := json.Marshal(wire)
391 if err != nil {452 if err != nil {
@@ -408,8 +469,14 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
408 // identities are load-shed with "servers are currently469 // identities are load-shed with "servers are currently
409 // overloaded" errors even when capacity is fine.470 // overloaded" errors even when capacity is fine.
410 httpReq.Header.Set("originator", "codex_cli_rs")471 httpReq.Header.Set("originator", "codex_cli_rs")
411- if c.capabilities.SessionHeader && logicalSessionID != "" {472+ if c.capabilities.SessionHeader && cacheSessionID != "" {
412- httpReq.Header.Set("session-id", logicalSessionID)473+ httpReq.Header.Set("session-id", cacheSessionID)
474+ }
475+ if c.capabilities.ThreadHeader && threadID != "" {
476+ httpReq.Header.Set("thread-id", threadID)
477+ }
478+ if c.capabilities.ClientRequestHeader && threadID != "" {
479+ httpReq.Header.Set("x-client-request-id", threadID)
413 }480 }
414 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")481 httpReq.Header.Set("user-agent", "codex_cli_rs/0.0.0")
415 } else {482 } else {
@@ -450,10 +517,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
450 // event vocabulary, so keeping the state machine here prevents the transports517 // event vocabulary, so keeping the state machine here prevents the transports
451 // from drifting in tool-call, reasoning, and usage handling.518 // from drifting in tool-call, reasoning, and usage handling.
452 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {519 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {
453- c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed)520+ var observe func(string, Message)
521+ if completed != nil {
522+ observe = func(responseID string, _ Message) { completed(responseID) }
523+ }
524+ c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe)
454 }525 }
455 526
456-func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) {527+func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) {
457 model, _ := c.findModel(req.Model)528 model, _ := c.findModel(req.Model)
458 providerName := c.providerName529 providerName := c.providerName
459 if providerName == "" {530 if providerName == "" {
@@ -659,11 +730,9 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
659 Response struct {730 Response struct {
660 ID string `json:"id"`731 ID string `json:"id"`
661 Usage struct {732 Usage struct {
662- InputTokens int `json:"input_tokens"`733+ InputTokens int `json:"input_tokens"`
663- OutputTokens int `json:"output_tokens"`734+ OutputTokens int `json:"output_tokens"`
664- InputTokensDetails struct {735+ InputTokensDetails *openAIInputTokensDetails `json:"input_tokens_details"`
665- CachedTokens int `json:"cached_tokens"`
666- } `json:"input_tokens_details"`
667 OutputTokensDetails *struct {736 OutputTokensDetails *struct {
668 ReasoningTokens int `json:"reasoning_tokens"`737 ReasoningTokens int `json:"reasoning_tokens"`
669 } `json:"output_tokens_details"`738 } `json:"output_tokens_details"`
@@ -672,12 +741,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
672 } `json:"response"`741 } `json:"response"`
673 }742 }
674 _ = json.Unmarshal([]byte(ev.Data), &p)743 _ = json.Unmarshal([]byte(ev.Data), &p)
675- usage.InputTokens = p.Response.Usage.InputTokens - p.Response.Usage.InputTokensDetails.CachedTokens744+ usage = normalizeOpenAIUsage(p.Response.Usage.InputTokens, p.Response.Usage.OutputTokens, p.Response.Usage.InputTokensDetails)
676- if usage.InputTokens < 0 {
677- usage.InputTokens = p.Response.Usage.InputTokens
678- }
679- usage.OutputTokens = p.Response.Usage.OutputTokens
680- usage.CacheReadTokens = p.Response.Usage.InputTokensDetails.CachedTokens
681 if details := p.Response.Usage.OutputTokensDetails; details != nil {745 if details := p.Response.Usage.OutputTokensDetails; details != nil {
682 usage.ReasoningTokens = details.ReasoningTokens746 usage.ReasoningTokens = details.ReasoningTokens
683 usage.ReasoningTokensKnown = true747 usage.ReasoningTokensKnown = true
@@ -696,7 +760,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
696 stop = StopEnd760 stop = StopEnd
697 }761 }
698 if completed != nil && p.Response.ID != "" {762 if completed != nil && p.Response.ID != "" {
699- completed(p.Response.ID)763+ completed(p.Response.ID, assemble())
700 }764 }
701 sendDone()765 sendDone()
702 return766 return
M packages/provider/openai_codex_test.go +48 -12
@@ -13,6 +13,23 @@ import (
1313 "golang.org/x/net/websocket"
1414 )
1515
16+func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
17+ newWire := func(text string) *codexRequest {
18+ return &codexRequest{
19+ PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"},
20+ Input: []any{codexInputMessage{Role: "user", Content: []any{
21+ codexInputText{Type: "input_text", Text: text},
22+ }}},
23+ }
24+ }
25+ if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible {
26+ t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics)
27+ }
28+ if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible {
29+ t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics)
30+ }
31+}
32+
1633 // An image-only tool result must not serialize to an empty
1734 // function_call_output (the Responses API may reject it) and a
1835 // following user-message image must serialize as input_image so the
@@ -92,7 +109,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
92109
93110 events, err := c.Stream(context.Background(), Request{
94111 Model: "gpt-5.6-terra",
95- Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"},
112+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
96113 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
97114 })
98115 if err != nil {
@@ -110,11 +127,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
110127 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
111128 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
112129 }
113- if gotBody.PromptCacheKey != "logical-session-1" {
114- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
130+ if gotBody.PromptCacheKey != "cache-root-1" {
131+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
115132 }
116- if gotReq.Header.Get("session-id") != "logical-session-1" {
117- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
133+ if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" {
134+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
118135 }
119136 }
120137
@@ -187,7 +204,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187204
188205 events, err := ws.Stream(context.Background(), Request{
189206 Model: "gpt-5.6-sol",
190- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
207+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
191208 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
192209 })
193210 if err != nil {
@@ -202,8 +219,27 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
202219 if gotReq.Header.Get("session-id") != "" {
203220 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
204221 }
205- if gotBody.PromptCacheKey != "logical-session-2" {
206- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)
222+ if gotBody.PromptCacheKey != "cache-root-2" {
223+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
224+ }
225+ if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
226+ t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
227+ }
228+ boundary, ok := gotBody.Input[0].(map[string]any)
229+ if !ok || boundary["role"] != "developer" {
230+ t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
231+ }
232+ content, ok := boundary["content"].([]any)
233+ if !ok || len(content) != 1 {
234+ t.Fatalf("stable cache boundary content = %#v", boundary)
235+ }
236+ text, ok := content[0].(map[string]any)
237+ if !ok || text["type"] != "input_text" {
238+ t.Fatalf("stable cache text block = %#v", content)
239+ }
240+ breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
241+ if !ok || breakpoint["mode"] != "explicit" {
242+ t.Fatalf("stable cache breakpoint = %#v", text)
207243 }
208244 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209245 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
@@ -220,7 +256,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
220256 }
221257 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
222258 })
223- events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}})
259+ events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
224260 if err != nil {
225261 t.Fatal(err)
226262 }
@@ -272,7 +308,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
272308
273309 events, err := c.Stream(context.Background(), Request{
274310 Model: "gpt-5.6-sol",
275- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},
311+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
276312 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
277313 })
278314 if err != nil {
@@ -290,8 +326,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
290326 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
291327 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
292328 }
293- if gotReq.Header.Get("session-id") != "logical-session-2" {
294- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))
329+ if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" {
330+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
295331 }
296332 if !strings.Contains(body.String(), "prompt_cache_key") {
297333 t.Fatalf("request missing prompt_cache_key: %s", body.String())
@@ -13,6 +13,23 @@ import (
13 "golang.org/x/net/websocket"13 "golang.org/x/net/websocket"
14 )14 )
15 15
16+func TestResponsesCacheDiagnosticsRequireEstimatedThreshold(t *testing.T) {
17+ newWire := func(text string) *codexRequest {
18+ return &codexRequest{
19+ PromptCacheOptions: &codexPromptCacheOptions{Mode: "implicit"},
20+ Input: []any{codexInputMessage{Role: "user", Content: []any{
21+ codexInputText{Type: "input_text", Text: text},
22+ }}},
23+ }
24+ }
25+ if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 64)), "http_sse", "full"); diagnostics.Eligible {
26+ t.Fatalf("short request diagnostics = %#v, want ineligible", diagnostics)
27+ }
28+ if diagnostics := responsesCacheDiagnostics(newWire(strings.Repeat("a", 5_000)), "http_sse", "full"); !diagnostics.Eligible {
29+ t.Fatalf("long request diagnostics = %#v, want eligible", diagnostics)
30+ }
31+}
32+
16 // An image-only tool result must not serialize to an empty33 // An image-only tool result must not serialize to an empty
17 // function_call_output (the Responses API may reject it) and a34 // function_call_output (the Responses API may reject it) and a
18 // following user-message image must serialize as input_image so the35 // following user-message image must serialize as input_image so the
@@ -92,7 +109,7 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
92 109
93 events, err := c.Stream(context.Background(), Request{110 events, err := c.Stream(context.Background(), Request{
94 Model: "gpt-5.6-terra",111 Model: "gpt-5.6-terra",
95- Context: RequestContext{SessionID: "logical-session-1", TurnID: "turn-1"},112+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
96 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},113 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
97 })114 })
98 if err != nil {115 if err != nil {
@@ -110,11 +127,11 @@ func TestCodexPreviewModelUsesCodexCLIShape(t *testing.T) {
110 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {127 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
111 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))128 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
112 }129 }
113- if gotBody.PromptCacheKey != "logical-session-1" {130+ if gotBody.PromptCacheKey != "cache-root-1" {
114- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)131+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
115 }132 }
116- if gotReq.Header.Get("session-id") != "logical-session-1" {133+ if gotReq.Header.Get("session-id") != "cache-root-1" || gotReq.Header.Get("thread-id") != "thread-1" || gotReq.Header.Get("x-client-request-id") != "thread-1" {
117- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))134+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
118 }135 }
119 }136 }
120 137
@@ -187,7 +204,7 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
187 204
188 events, err := ws.Stream(context.Background(), Request{205 events, err := ws.Stream(context.Background(), Request{
189 Model: "gpt-5.6-sol",206 Model: "gpt-5.6-sol",
190- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},207+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
191 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},208 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
192 })209 })
193 if err != nil {210 if err != nil {
@@ -202,8 +219,27 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
202 if gotReq.Header.Get("session-id") != "" {219 if gotReq.Header.Get("session-id") != "" {
203 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))220 t.Fatalf("session-id = %q", gotReq.Header.Get("session-id"))
204 }221 }
205- if gotBody.PromptCacheKey != "logical-session-2" {222+ if gotBody.PromptCacheKey != "cache-root-2" {
206- t.Fatalf("prompt_cache_key = %q, want logical session ID", gotBody.PromptCacheKey)223+ t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
224+ }
225+ if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
226+ t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
227+ }
228+ boundary, ok := gotBody.Input[0].(map[string]any)
229+ if !ok || boundary["role"] != "developer" {
230+ t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
231+ }
232+ content, ok := boundary["content"].([]any)
233+ if !ok || len(content) != 1 {
234+ t.Fatalf("stable cache boundary content = %#v", boundary)
235+ }
236+ text, ok := content[0].(map[string]any)
237+ if !ok || text["type"] != "input_text" {
238+ t.Fatalf("stable cache text block = %#v", content)
239+ }
240+ breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
241+ if !ok || breakpoint["mode"] != "explicit" {
242+ t.Fatalf("stable cache breakpoint = %#v", text)
207 }243 }
208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {244 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)245 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
@@ -220,7 +256,7 @@ func TestCustomResponsesEndpointDoesNotInheritOpenAICacheExtensions(t *testing.T
220 }256 }
221 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil257 return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"text/event-stream"}}, Body: io.NopCloser(strings.NewReader(""))}, nil
222 })258 })
223- events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "logical-session", TurnID: "turn"}})259+ events, err := c.Stream(context.Background(), Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root", ThreadID: "thread", TurnID: "turn"}})
224 if err != nil {260 if err != nil {
225 t.Fatal(err)261 t.Fatal(err)
226 }262 }
@@ -272,7 +308,7 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
272 308
273 events, err := c.Stream(context.Background(), Request{309 events, err := c.Stream(context.Background(), Request{
274 Model: "gpt-5.6-sol",310 Model: "gpt-5.6-sol",
275- Context: RequestContext{SessionID: "logical-session-2", TurnID: "turn-2"},311+ Context: RequestContext{CacheSessionID: "cache-root-2", ThreadID: "thread-2", TurnID: "turn-2"},
276 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},312 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "hi"}}}},
277 })313 })
278 if err != nil {314 if err != nil {
@@ -290,8 +326,8 @@ func TestCodexSubscriptionAlwaysUsesCodexCLIShape(t *testing.T) {
290 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {326 if gotReq.Header.Get("user-agent") != "codex_cli_rs/0.0.0" {
291 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))327 t.Fatalf("user-agent = %q", gotReq.Header.Get("user-agent"))
292 }328 }
293- if gotReq.Header.Get("session-id") != "logical-session-2" {329+ if gotReq.Header.Get("session-id") != "cache-root-2" || gotReq.Header.Get("thread-id") != "thread-2" || gotReq.Header.Get("x-client-request-id") != "thread-2" {
294- t.Fatalf("session-id = %q, want logical session ID", gotReq.Header.Get("session-id"))330+ t.Fatalf("routing headers = session=%q thread=%q request=%q", gotReq.Header.Get("session-id"), gotReq.Header.Get("thread-id"), gotReq.Header.Get("x-client-request-id"))
295 }331 }
296 if !strings.Contains(body.String(), "prompt_cache_key") {332 if !strings.Contains(body.String(), "prompt_cache_key") {
297 t.Fatalf("request missing prompt_cache_key: %s", body.String())333 t.Fatalf("request missing prompt_cache_key: %s", body.String())
M packages/provider/openai_responses.go +3 -2
@@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
7878 errorLabel: "openai",
7979 providerName: name,
8080 capabilities: responsesCapabilities{
81- // Only the official OpenAI provider declares this extension.
81+ // Only the official OpenAI provider declares these extensions.
8282 // A Responses-shaped custom endpoint must opt in through its
8383 // own client rather than inheriting OpenAI's cache contract.
84- StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL),
84+ StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL),
85+ ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL),
8586 },
8687 http: httpClient,
8788 }
@@ -78,10 +78,11 @@ func NewOpenAIResponsesNamed(apiKey, baseURL, name string) Client {
78 errorLabel: "openai",78 errorLabel: "openai",
79 providerName: name,79 providerName: name,
80 capabilities: responsesCapabilities{80 capabilities: responsesCapabilities{
81- // Only the official OpenAI provider declares this extension.81+ // Only the official OpenAI provider declares these extensions.
82 // A Responses-shaped custom endpoint must opt in through its82 // A Responses-shaped custom endpoint must opt in through its
83 // own client rather than inheriting OpenAI's cache contract.83 // own client rather than inheriting OpenAI's cache contract.
84- StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL),84+ StablePromptCacheKey: officialOpenAIResponsesEndpoint(name, baseURL),
85+ ExplicitPromptCacheBreakpoint: officialOpenAIResponsesEndpoint(name, baseURL),
85 },86 },
86 http: httpClient,87 http: httpClient,
87 }88 }
M packages/provider/openai_responses_websocket.go +94 -26
@@ -2,7 +2,9 @@ package provider
22
33 import (
44 "context"
5+ "crypto/sha256"
56 "crypto/tls"
7+ "encoding/hex"
68 "encoding/json"
79 "fmt"
810 "net/http"
@@ -10,6 +12,7 @@ import (
1012 "reflect"
1113 "strings"
1214 "sync"
15+ "time"
1316
1417 "golang.org/x/net/websocket"
1518 )
@@ -36,7 +39,11 @@ type responsesWebSocketSession struct {
3639 raw chan sseEvent
3740 readerDone chan struct{}
3841
39- lastRequest []Message
42+ // lastRequest is the complete request accepted by the server. Continuation
43+ // must compare every response-context setting, not merely its transcript.
44+ lastRequest Request
45+ lastBaseline string
46+ lastOutput Message
4047 lastResponseID string
4148 }
4249
@@ -152,15 +159,16 @@ func responsesWebSocketURL(endpoint string) (string, error) {
152159 }
153160
154161 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
155- // WebSocket continuation state is keyed by the durable core session ID.
156- // Requests without that identity retain HTTP/SSE semantics rather than
157- // accidentally pooling unrelated conversations.
158- sessionID := strings.TrimSpace(req.Context.SessionID)
159- if sessionID == "" {
162+ // WebSocket continuation state is isolated per conversation thread. Cache
163+ // affinity may be shared by root and resident children, but their server
164+ // response chains must never be pooled.
165+ threadID := strings.TrimSpace(req.Context.ThreadID)
166+ if threadID == "" {
160167 return c.http.Stream(ctx, req)
161168 }
169+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
162170
163- session := c.session(sessionID)
171+ session := c.session(threadID)
164172 select {
165173 case <-ctx.Done():
166174 return nil, ctx.Err()
@@ -174,8 +182,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
174182 }
175183 }()
176184
177- streamReq, previousResponseID := session.incrementalRequest(req)
178- payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID)
185+ fullBaseline, err := c.canonicalResponsesBaseline(req, cacheSessionID)
186+ if err != nil {
187+ return nil, err
188+ }
189+ streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline)
190+ payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
179191 if err != nil {
180192 return nil, err
181193 }
@@ -183,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
183195 if !responsesWebSocketHTTPCompatible(c.http.http) {
184196 return c.http.Stream(ctx, req)
185197 }
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+ }
186205 reportRequestAttempt(req.Lifecycle, 1, 2)
187206 if err := session.ensureConnected(ctx, c.dial); err != nil {
188207 // A connection that never became usable has no server-side turn. The
@@ -215,7 +234,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
215234 // HTTP/SSE. That keeps the stable cache key and logical IDs intact
216235 // without ever reusing the invalid previous_response_id.
217236 session.invalidate()
218- reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID)
237+ reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID)
219238 if err != nil {
220239 c.forwardResponsesHTTP(ctx, req, out)
221240 return
@@ -239,12 +258,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
239258
240259 completed := false
241260 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)
242- c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) {
261+ c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) {
243262 stateMu.Lock()
244263 terminal = true
245264 stateMu.Unlock()
246265 completed = true
247- session.recordCompleted(req.Messages, responseID)
266+ session.recordCompleted(req, fullBaseline, output, responseID)
248267 })
249268 close(finished)
250269 if !completed {
@@ -254,7 +273,19 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
254273 return out, nil
255274 }
256275
257-func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) {
276+// canonicalResponsesBaseline hashes the complete, encoded WebSocket request
277+// without a server response ID. The digest lets continuation compare every
278+// wire-visible setting without retaining prompts, IDs, or credentials.
279+func (c *responsesWebSocketClient) canonicalResponsesBaseline(req Request, cacheSessionID string) (string, error) {
280+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281+ if err != nil {
282+ return "", err
283+ }
284+ digest := sha256.Sum256(payload)
285+ return hex.EncodeToString(digest[:]), nil
286+}
287+
288+func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) {
258289 wire, err := c.http.buildRequest(req)
259290 if err != nil {
260291 return nil, err
@@ -262,8 +293,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
262293 if c.http.modelName != nil {
263294 wire.Model = c.http.modelName(wire.Model)
264295 }
265- if c.http.capabilities.StablePromptCacheKey {
266- wire.PromptCacheKey = sessionID
296+ if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" {
297+ wire.PromptCacheKey = cacheSessionID
267298 }
268299 wire.Stream = false // WebSocket mode does not send HTTP stream controls.
269300 wire.PreviousResponseID = previousResponseID
@@ -276,8 +307,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
276307 // reconnectFull establishes one replacement WebSocket turn after a cached
277308 // continuation failed. A fresh connection cannot rely on the previous server
278309 // response, so it always sends the complete local transcript.
279-func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
280- payload, err := c.websocketPayload(req, sessionID, "")
310+func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
311+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281312 if err != nil {
282313 return nil, nil, sseEvent{}, "", err
283314 }
@@ -417,21 +448,52 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req
417448 }
418449 }
419450
420-func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) {
451+func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSessionID string, canonicalBaseline func(Request, string) (string, error)) (Request, string) {
421452 s.mu.Lock()
422453 defer s.mu.Unlock()
423- if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) {
454+ previous := s.lastRequest
455+ if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
456+ return req, ""
457+ }
458+ prefix := req
459+ prefix.Messages = append([]Message(nil), req.Messages[:len(previous.Messages)]...)
460+ baseline, err := canonicalBaseline(prefix, cacheSessionID)
461+ if err != nil || baseline != s.lastBaseline {
424462 return req, ""
425463 }
426- // The cached response already contains the assistant output from the last
427- // generation. Send only the new tool results and/or user message.
428- req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...)
429- for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
464+ // The cached response already contains the exact assistant output from the
465+ // last generation. It must be replayed verbatim in the caller transcript
466+ // before we can safely send only the strict new tool-result/user suffix.
467+ fullRequest := req
468+ req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
469+ if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
470+ if !sameResponsesOutput(req.Messages[0], s.lastOutput) {
471+ return fullRequest, ""
472+ }
430473 req.Messages = req.Messages[1:]
431474 }
432475 return req, s.lastResponseID
433476 }
434477
478+func sameResponsesOutput(current, previous Message) bool {
479+ // Provider message timestamps are local presentation metadata, not server
480+ // response state. All visible content, call IDs, reasoning replay, and
481+ // metadata remain part of the strict baseline.
482+ current.Time = time.Time{}
483+ previous.Time = time.Time{}
484+ return reflect.DeepEqual(current, previous)
485+}
486+
487+func sameResponsesContinuationConfig(previous, current Request) bool {
488+ previous.Messages = nil
489+ previous.Context.TurnID = ""
490+ previous.Lifecycle = nil
491+ current.Messages = nil
492+ current.Context.TurnID = ""
493+ current.Lifecycle = nil
494+ return reflect.DeepEqual(previous, current)
495+}
496+
435497 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
436498 s.mu.Lock()
437499 if s.conn != nil && s.raw != nil {
@@ -460,10 +522,14 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
460522 return s.conn, s.raw
461523 }
462524
463-func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) {
525+func (s *responsesWebSocketSession) recordCompleted(request Request, baseline string, output Message, responseID string) {
464526 s.mu.Lock()
465527 defer s.mu.Unlock()
466- s.lastRequest = append(s.lastRequest[:0], messages...)
528+ request.Messages = append([]Message(nil), request.Messages...)
529+ request.Lifecycle = nil
530+ s.lastRequest = request
531+ s.lastBaseline = baseline
532+ s.lastOutput = output
467533 s.lastResponseID = responseID
468534 }
469535
@@ -492,7 +558,9 @@ func (s *responsesWebSocketSession) invalidateLocked() {
492558 s.conn = nil
493559 s.raw = nil
494560 s.readerDone = nil
495- s.lastRequest = nil
561+ s.lastRequest = Request{}
562+ s.lastBaseline = ""
563+ s.lastOutput = Message{}
496564 s.lastResponseID = ""
497565 }
498566
@@ -2,7 +2,9 @@ package provider
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/sha256"
5 "crypto/tls"6 "crypto/tls"
7+ "encoding/hex"
6 "encoding/json"8 "encoding/json"
7 "fmt"9 "fmt"
8 "net/http"10 "net/http"
@@ -10,6 +12,7 @@ import (
10 "reflect"12 "reflect"
11 "strings"13 "strings"
12 "sync"14 "sync"
15+ "time"
13 16
14 "golang.org/x/net/websocket"17 "golang.org/x/net/websocket"
15 )18 )
@@ -36,7 +39,11 @@ type responsesWebSocketSession struct {
36 raw chan sseEvent39 raw chan sseEvent
37 readerDone chan struct{}40 readerDone chan struct{}
38 41
39- lastRequest []Message42+ // lastRequest is the complete request accepted by the server. Continuation
43+ // must compare every response-context setting, not merely its transcript.
44+ lastRequest Request
45+ lastBaseline string
46+ lastOutput Message
40 lastResponseID string47 lastResponseID string
41 }48 }
42 49
@@ -152,15 +159,16 @@ func responsesWebSocketURL(endpoint string) (string, error) {
152 }159 }
153 160
154 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {161 func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-chan Event, error) {
155- // WebSocket continuation state is keyed by the durable core session ID.162+ // WebSocket continuation state is isolated per conversation thread. Cache
156- // Requests without that identity retain HTTP/SSE semantics rather than163+ // affinity may be shared by root and resident children, but their server
157- // accidentally pooling unrelated conversations.164+ // response chains must never be pooled.
158- sessionID := strings.TrimSpace(req.Context.SessionID)165+ threadID := strings.TrimSpace(req.Context.ThreadID)
159- if sessionID == "" {166+ if threadID == "" {
160 return c.http.Stream(ctx, req)167 return c.http.Stream(ctx, req)
161 }168 }
169+ cacheSessionID := strings.TrimSpace(req.Context.CacheSessionID)
162 170
163- session := c.session(sessionID)171+ session := c.session(threadID)
164 select {172 select {
165 case <-ctx.Done():173 case <-ctx.Done():
166 return nil, ctx.Err()174 return nil, ctx.Err()
@@ -174,8 +182,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
174 }182 }
175 }()183 }()
176 184
177- streamReq, previousResponseID := session.incrementalRequest(req)185+ fullBaseline, err := c.canonicalResponsesBaseline(req, cacheSessionID)
178- payload, err := c.websocketPayload(streamReq, sessionID, previousResponseID)186+ if err != nil {
187+ return nil, err
188+ }
189+ streamReq, previousResponseID := session.incrementalRequest(req, cacheSessionID, c.canonicalResponsesBaseline)
190+ payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
179 if err != nil {191 if err != nil {
180 return nil, err192 return nil, err
181 }193 }
@@ -183,6 +195,13 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
183 if !responsesWebSocketHTTPCompatible(c.http.http) {195 if !responsesWebSocketHTTPCompatible(c.http.http) {
184 return c.http.Stream(ctx, req)196 return c.http.Stream(ctx, req)
185 }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+ }
186 reportRequestAttempt(req.Lifecycle, 1, 2)205 reportRequestAttempt(req.Lifecycle, 1, 2)
187 if err := session.ensureConnected(ctx, c.dial); err != nil {206 if err := session.ensureConnected(ctx, c.dial); err != nil {
188 // A connection that never became usable has no server-side turn. The207 // A connection that never became usable has no server-side turn. The
@@ -215,7 +234,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
215 // HTTP/SSE. That keeps the stable cache key and logical IDs intact234 // HTTP/SSE. That keeps the stable cache key and logical IDs intact
216 // without ever reusing the invalid previous_response_id.235 // without ever reusing the invalid previous_response_id.
217 session.invalidate()236 session.invalidate()
218- reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, sessionID)237+ reconnected, reconnectRaw, reconnectFirst, reconnectResponseID, err := c.reconnectFull(ctx, session, req, cacheSessionID)
219 if err != nil {238 if err != nil {
220 c.forwardResponsesHTTP(ctx, req, out)239 c.forwardResponsesHTTP(ctx, req, out)
221 return240 return
@@ -239,12 +258,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
239 258
240 completed := false259 completed := false
241 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)260 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)
242- c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) {261+ c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) {
243 stateMu.Lock()262 stateMu.Lock()
244 terminal = true263 terminal = true
245 stateMu.Unlock()264 stateMu.Unlock()
246 completed = true265 completed = true
247- session.recordCompleted(req.Messages, responseID)266+ session.recordCompleted(req, fullBaseline, output, responseID)
248 })267 })
249 close(finished)268 close(finished)
250 if !completed {269 if !completed {
@@ -254,7 +273,19 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
254 return out, nil273 return out, nil
255 }274 }
256 275
257-func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, previousResponseID string) ([]byte, error) {276+// canonicalResponsesBaseline hashes the complete, encoded WebSocket request
277+// without a server response ID. The digest lets continuation compare every
278+// wire-visible setting without retaining prompts, IDs, or credentials.
279+func (c *responsesWebSocketClient) canonicalResponsesBaseline(req Request, cacheSessionID string) (string, error) {
280+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281+ if err != nil {
282+ return "", err
283+ }
284+ digest := sha256.Sum256(payload)
285+ return hex.EncodeToString(digest[:]), nil
286+}
287+
288+func (c *responsesWebSocketClient) websocketPayload(req Request, cacheSessionID, previousResponseID string) ([]byte, error) {
258 wire, err := c.http.buildRequest(req)289 wire, err := c.http.buildRequest(req)
259 if err != nil {290 if err != nil {
260 return nil, err291 return nil, err
@@ -262,8 +293,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
262 if c.http.modelName != nil {293 if c.http.modelName != nil {
263 wire.Model = c.http.modelName(wire.Model)294 wire.Model = c.http.modelName(wire.Model)
264 }295 }
265- if c.http.capabilities.StablePromptCacheKey {296+ if c.http.capabilities.StablePromptCacheKey && cacheSessionID != "" {
266- wire.PromptCacheKey = sessionID297+ wire.PromptCacheKey = cacheSessionID
267 }298 }
268 wire.Stream = false // WebSocket mode does not send HTTP stream controls.299 wire.Stream = false // WebSocket mode does not send HTTP stream controls.
269 wire.PreviousResponseID = previousResponseID300 wire.PreviousResponseID = previousResponseID
@@ -276,8 +307,8 @@ func (c *responsesWebSocketClient) websocketPayload(req Request, sessionID, prev
276 // reconnectFull establishes one replacement WebSocket turn after a cached307 // reconnectFull establishes one replacement WebSocket turn after a cached
277 // continuation failed. A fresh connection cannot rely on the previous server308 // continuation failed. A fresh connection cannot rely on the previous server
278 // response, so it always sends the complete local transcript.309 // response, so it always sends the complete local transcript.
279-func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, sessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {310+func (c *responsesWebSocketClient) reconnectFull(ctx context.Context, session *responsesWebSocketSession, req Request, cacheSessionID string) (*websocket.Conn, <-chan sseEvent, sseEvent, string, error) {
280- payload, err := c.websocketPayload(req, sessionID, "")311+ payload, err := c.websocketPayload(req, cacheSessionID, "")
281 if err != nil {312 if err != nil {
282 return nil, nil, sseEvent{}, "", err313 return nil, nil, sseEvent{}, "", err
283 }314 }
@@ -417,21 +448,52 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req
417 }448 }
418 }449 }
419 450
420-func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) {451+func (s *responsesWebSocketSession) incrementalRequest(req Request, cacheSessionID string, canonicalBaseline func(Request, string) (string, error)) (Request, string) {
421 s.mu.Lock()452 s.mu.Lock()
422 defer s.mu.Unlock()453 defer s.mu.Unlock()
423- if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) {454+ previous := s.lastRequest
455+ if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
456+ return req, ""
457+ }
458+ prefix := req
459+ prefix.Messages = append([]Message(nil), req.Messages[:len(previous.Messages)]...)
460+ baseline, err := canonicalBaseline(prefix, cacheSessionID)
461+ if err != nil || baseline != s.lastBaseline {
424 return req, ""462 return req, ""
425 }463 }
426- // The cached response already contains the assistant output from the last464+ // The cached response already contains the exact assistant output from the
427- // generation. Send only the new tool results and/or user message.465+ // last generation. It must be replayed verbatim in the caller transcript
428- req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...)466+ // before we can safely send only the strict new tool-result/user suffix.
429- for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {467+ fullRequest := req
468+ req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
469+ if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
470+ if !sameResponsesOutput(req.Messages[0], s.lastOutput) {
471+ return fullRequest, ""
472+ }
430 req.Messages = req.Messages[1:]473 req.Messages = req.Messages[1:]
431 }474 }
432 return req, s.lastResponseID475 return req, s.lastResponseID
433 }476 }
434 477
478+func sameResponsesOutput(current, previous Message) bool {
479+ // Provider message timestamps are local presentation metadata, not server
480+ // response state. All visible content, call IDs, reasoning replay, and
481+ // metadata remain part of the strict baseline.
482+ current.Time = time.Time{}
483+ previous.Time = time.Time{}
484+ return reflect.DeepEqual(current, previous)
485+}
486+
487+func sameResponsesContinuationConfig(previous, current Request) bool {
488+ previous.Messages = nil
489+ previous.Context.TurnID = ""
490+ previous.Lifecycle = nil
491+ current.Messages = nil
492+ current.Context.TurnID = ""
493+ current.Lifecycle = nil
494+ return reflect.DeepEqual(previous, current)
495+}
496+
435 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {497 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
436 s.mu.Lock()498 s.mu.Lock()
437 if s.conn != nil && s.raw != nil {499 if s.conn != nil && s.raw != nil {
@@ -460,10 +522,14 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
460 return s.conn, s.raw522 return s.conn, s.raw
461 }523 }
462 524
463-func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) {525+func (s *responsesWebSocketSession) recordCompleted(request Request, baseline string, output Message, responseID string) {
464 s.mu.Lock()526 s.mu.Lock()
465 defer s.mu.Unlock()527 defer s.mu.Unlock()
466- s.lastRequest = append(s.lastRequest[:0], messages...)528+ request.Messages = append([]Message(nil), request.Messages...)
529+ request.Lifecycle = nil
530+ s.lastRequest = request
531+ s.lastBaseline = baseline
532+ s.lastOutput = output
467 s.lastResponseID = responseID533 s.lastResponseID = responseID
468 }534 }
469 535
@@ -492,7 +558,9 @@ func (s *responsesWebSocketSession) invalidateLocked() {
492 s.conn = nil558 s.conn = nil
493 s.raw = nil559 s.raw = nil
494 s.readerDone = nil560 s.readerDone = nil
495- s.lastRequest = nil561+ s.lastRequest = Request{}
562+ s.lastBaseline = ""
563+ s.lastOutput = Message{}
496 s.lastResponseID = ""564 s.lastResponseID = ""
497 }565 }
498 566
M packages/provider/openai_responses_websocket_test.go +120 -9
@@ -3,8 +3,10 @@ package provider
33 import (
44 "context"
55 "encoding/json"
6+ "io"
67 "net/http"
78 "net/http/httptest"
9+ "reflect"
810 "strings"
911 "sync"
1012 "testing"
@@ -80,10 +82,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t
8082 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
8183 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
8284 })
83- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
85+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
8486 assertCompletedResponse(t, client, first)
8587 lifecycle := &recordingRequestLifecycle{}
86- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
88+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
8789 assertCompletedResponseText(t, client, second, "reconnected")
8890 mu.Lock()
8991 defer mu.Unlock()
@@ -163,14 +165,15 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
163165 first := Request{
164166 Model: "gpt-5.6-sol",
165167 FastMode: true,
166- Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"},
168+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
167169 Lifecycle: lifecycle,
168170 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
169171 }
170172 assertCompletedResponse(t, client, first)
171173 second := Request{
172- Model: "gpt-5.6-sol",
173- Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"},
174+ Model: "gpt-5.6-sol",
175+ FastMode: true,
176+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174177 Messages: append(append([]Message(nil), first.Messages...),
175178 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176179 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
@@ -195,7 +198,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
195198 if _, ok := requests[0]["stream"]; ok {
196199 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])
197200 }
198- if got := requests[0]["prompt_cache_key"]; got != "session-1" {
201+ if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" {
199202 t.Fatalf("prompt_cache_key = %#v", got)
200203 }
201204 if got := requests[0]["service_tier"]; got != fastModeServiceTier {
@@ -222,6 +225,73 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
222225 }
223226 }
224227
228+func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229+ mappedModel := "gpt-5.6-sol-a"
230+ client := newResponsesWebSocketClient(&codexClient{
231+ providerName: "openai",
232+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
233+ modelName: func(string) string { return mappedModel },
234+ }).(*responsesWebSocketClient)
235+ first := Request{
236+ Model: "gpt-5.6-sol",
237+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
238+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
239+ }
240+ baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
241+ if err != nil {
242+ t.Fatal(err)
243+ }
244+ session := &responsesWebSocketSession{}
245+ session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, "resp_1")
246+
247+ mappedModel = "gpt-5.6-sol-b"
248+ second := Request{
249+ Model: first.Model,
250+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
251+ Messages: append(append([]Message(nil), first.Messages...),
252+ Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
253+ Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
254+ ),
255+ }
256+ streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
257+ if previousResponseID != "" {
258+ t.Fatalf("previous response ID = %q, want full request after wire baseline change", previousResponseID)
259+ }
260+ if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
261+ t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
262+ }
263+}
264+
265+func TestResponsesWebSocketAssistantMismatchForcesCompleteTranscript(t *testing.T) {
266+ client := newResponsesWebSocketClient(&codexClient{
267+ providerName: "openai", capabilities: responsesCapabilities{StablePromptCacheKey: true},
268+ }).(*responsesWebSocketClient)
269+ first := Request{
270+ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
271+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
272+ }
273+ baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ session := &responsesWebSocketSession{}
278+ session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "actual"}}}, "resp_1")
279+ second := Request{
280+ Model: first.Model, Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
281+ Messages: append(append([]Message(nil), first.Messages...),
282+ Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "modified"}}},
283+ Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
284+ ),
285+ }
286+ streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
287+ if previousResponseID != "" {
288+ t.Fatalf("previous response ID = %q, want complete request", previousResponseID)
289+ }
290+ if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
291+ t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
292+ }
293+}
294+
225295 func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) {
226296 closed := make(chan struct{})
227297 var closeOnce sync.Once
@@ -242,7 +312,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T)
242312 capabilities: responsesCapabilities{StablePromptCacheKey: true},
243313 })
244314 ctx, cancel := context.WithCancel(context.Background())
245- stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}})
315+ stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}})
246316 if err != nil {
247317 t.Fatal(err)
248318 }
@@ -290,9 +360,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin
290360 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
291361 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
292362 })
293- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
363+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
294364 assertCompletedResponseText(t, client, first, "first")
295- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
365+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
296366 assertCompletedResponseText(t, client, second, "second")
297367 }
298368
@@ -309,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) {
309379 }
310380 }
311381
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+
312423 func assertCompletedResponse(t *testing.T, client Client, req Request) {
313424 assertCompletedResponseText(t, client, req, "ok")
314425 }
@@ -3,8 +3,10 @@ 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"
9+ "reflect"
8 "strings"10 "strings"
9 "sync"11 "sync"
10 "testing"12 "testing"
@@ -80,10 +82,10 @@ func TestResponsesWebSocketReconnectsMissingPreviousResponseWithFullContext(t *t
80 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",82 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
81 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},83 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
82 })84 })
83- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}85+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
84 assertCompletedResponse(t, client, first)86 assertCompletedResponse(t, client, first)
85 lifecycle := &recordingRequestLifecycle{}87 lifecycle := &recordingRequestLifecycle{}
86- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}88+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Lifecycle: lifecycle, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
87 assertCompletedResponseText(t, client, second, "reconnected")89 assertCompletedResponseText(t, client, second, "reconnected")
88 mu.Lock()90 mu.Lock()
89 defer mu.Unlock()91 defer mu.Unlock()
@@ -163,14 +165,15 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
163 first := Request{165 first := Request{
164 Model: "gpt-5.6-sol",166 Model: "gpt-5.6-sol",
165 FastMode: true,167 FastMode: true,
166- Context: RequestContext{SessionID: "session-1", TurnID: "turn-1"},168+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
167 Lifecycle: lifecycle,169 Lifecycle: lifecycle,
168 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},170 Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
169 }171 }
170 assertCompletedResponse(t, client, first)172 assertCompletedResponse(t, client, first)
171 second := Request{173 second := Request{
172- Model: "gpt-5.6-sol",174+ Model: "gpt-5.6-sol",
173- Context: RequestContext{SessionID: "session-1", TurnID: "turn-2"},175+ FastMode: true,
176+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174 Messages: append(append([]Message(nil), first.Messages...),177 Messages: append(append([]Message(nil), first.Messages...),
175 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},178 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},179 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
@@ -195,7 +198,7 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
195 if _, ok := requests[0]["stream"]; ok {198 if _, ok := requests[0]["stream"]; ok {
196 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])199 t.Fatalf("WebSocket payload unexpectedly carries stream: %#v", requests[0])
197 }200 }
198- if got := requests[0]["prompt_cache_key"]; got != "session-1" {201+ if got := requests[0]["prompt_cache_key"]; got != "cache-root-1" {
199 t.Fatalf("prompt_cache_key = %#v", got)202 t.Fatalf("prompt_cache_key = %#v", got)
200 }203 }
201 if got := requests[0]["service_tier"]; got != fastModeServiceTier {204 if got := requests[0]["service_tier"]; got != fastModeServiceTier {
@@ -222,6 +225,73 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
222 }225 }
223 }226 }
224 227
228+func TestResponsesWebSocketWireBaselineChangeForcesFullRequest(t *testing.T) {
229+ mappedModel := "gpt-5.6-sol-a"
230+ client := newResponsesWebSocketClient(&codexClient{
231+ providerName: "openai",
232+ capabilities: responsesCapabilities{StablePromptCacheKey: true},
233+ modelName: func(string) string { return mappedModel },
234+ }).(*responsesWebSocketClient)
235+ first := Request{
236+ Model: "gpt-5.6-sol",
237+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
238+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
239+ }
240+ baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
241+ if err != nil {
242+ t.Fatal(err)
243+ }
244+ session := &responsesWebSocketSession{}
245+ session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}}, "resp_1")
246+
247+ mappedModel = "gpt-5.6-sol-b"
248+ second := Request{
249+ Model: first.Model,
250+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
251+ Messages: append(append([]Message(nil), first.Messages...),
252+ Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
253+ Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
254+ ),
255+ }
256+ streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
257+ if previousResponseID != "" {
258+ t.Fatalf("previous response ID = %q, want full request after wire baseline change", previousResponseID)
259+ }
260+ if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
261+ t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
262+ }
263+}
264+
265+func TestResponsesWebSocketAssistantMismatchForcesCompleteTranscript(t *testing.T) {
266+ client := newResponsesWebSocketClient(&codexClient{
267+ providerName: "openai", capabilities: responsesCapabilities{StablePromptCacheKey: true},
268+ }).(*responsesWebSocketClient)
269+ first := Request{
270+ Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-1"},
271+ Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}},
272+ }
273+ baseline, err := client.canonicalResponsesBaseline(first, first.Context.CacheSessionID)
274+ if err != nil {
275+ t.Fatal(err)
276+ }
277+ session := &responsesWebSocketSession{}
278+ session.recordCompleted(first, baseline, Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "actual"}}}, "resp_1")
279+ second := Request{
280+ Model: first.Model, Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
281+ Messages: append(append([]Message(nil), first.Messages...),
282+ Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "modified"}}},
283+ Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
284+ ),
285+ }
286+ streamReq, previousResponseID := session.incrementalRequest(second, second.Context.CacheSessionID, client.canonicalResponsesBaseline)
287+ if previousResponseID != "" {
288+ t.Fatalf("previous response ID = %q, want complete request", previousResponseID)
289+ }
290+ if !reflect.DeepEqual(streamReq.Messages, second.Messages) {
291+ t.Fatalf("stream messages = %#v, want complete transcript %#v", streamReq.Messages, second.Messages)
292+ }
293+}
294+
225 func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) {295 func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T) {
226 closed := make(chan struct{})296 closed := make(chan struct{})
227 var closeOnce sync.Once297 var closeOnce sync.Once
@@ -242,7 +312,7 @@ func TestResponsesWebSocketCancellationClosesOnlyActiveConnection(t *testing.T)
242 capabilities: responsesCapabilities{StablePromptCacheKey: true},312 capabilities: responsesCapabilities{StablePromptCacheKey: true},
243 })313 })
244 ctx, cancel := context.WithCancel(context.Background())314 ctx, cancel := context.WithCancel(context.Background())
245- stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}})315+ stream, err := client.Stream(ctx, Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}})
246 if err != nil {316 if err != nil {
247 t.Fatal(err)317 t.Fatal(err)
248 }318 }
@@ -290,9 +360,9 @@ func TestResponsesWebSocketIgnoresStaleTerminalFrameBeforeNextResponse(t *testin
290 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",360 token: "test-token", baseURL: server.URL + "/v1/responses", providerName: "openai",
291 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},361 capabilities: responsesCapabilities{StablePromptCacheKey: true}, http: &http.Client{},
292 })362 })
293- first := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}363+ first := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: []Message{{Role: RoleUser, Content: []Content{TextBlock{Text: "first"}}}}}
294 assertCompletedResponseText(t, client, first, "first")364 assertCompletedResponseText(t, client, first, "first")
295- second := Request{Model: "gpt-5.6-sol", Context: RequestContext{SessionID: "session-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}365+ second := Request{Model: "gpt-5.6-sol", Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1"}, Messages: append(append([]Message(nil), first.Messages...), Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "first"}}}, Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}})}
296 assertCompletedResponseText(t, client, second, "second")366 assertCompletedResponseText(t, client, second, "second")
297 }367 }
298 368
@@ -309,6 +379,47 @@ func TestResponsesWebSocketCloseReleasesSessionSockets(t *testing.T) {
309 }379 }
310 }380 }
311 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+
312 func assertCompletedResponse(t *testing.T, client Client, req Request) {423 func assertCompletedResponse(t *testing.T, client Client, req Request) {
313 assertCompletedResponseText(t, client, req, "ok")424 assertCompletedResponseText(t, client, req, "ok")
314 }425 }
A packages/provider/openai_usage.go +37 -0
@@ -0,0 +1,37 @@
1+package provider
2+
3+// openAIInputTokensDetails is shared by the Chat Completions and Responses
4+// streams. The details object is optional; its presence is the availability
5+// signal for cache accounting, including a known cache miss.
6+type openAIInputTokensDetails struct {
7+ CachedTokens int `json:"cached_tokens"`
8+ CacheWriteTokens int `json:"cache_write_tokens"`
9+}
10+
11+// normalizeOpenAIUsage converts OpenAI's total input count and optional cache
12+// detail into disjoint usage buckets. Malformed detail must not create a
13+// negative ordinary-input count or an unsupported cache metric, so it falls
14+// back to treating the total as ordinary input.
15+func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage {
16+ if inputTokens < 0 {
17+ inputTokens = 0
18+ }
19+ if outputTokens < 0 {
20+ outputTokens = 0
21+ }
22+
23+ usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens}
24+ if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 {
25+ return usage
26+ }
27+ if details.CachedTokens+details.CacheWriteTokens > inputTokens {
28+ return usage
29+ }
30+
31+ usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens
32+ usage.CacheReadTokens = details.CachedTokens
33+ usage.CacheWriteTokens = details.CacheWriteTokens
34+ usage.CacheMeasuredPromptTokens = inputTokens
35+ usage.CacheMeasuredReadTokens = details.CachedTokens
36+ return usage
37+}
@@ -0,0 +1,37 @@
1+package provider
2+
3+// openAIInputTokensDetails is shared by the Chat Completions and Responses
4+// streams. The details object is optional; its presence is the availability
5+// signal for cache accounting, including a known cache miss.
6+type openAIInputTokensDetails struct {
7+ CachedTokens int `json:"cached_tokens"`
8+ CacheWriteTokens int `json:"cache_write_tokens"`
9+}
10+
11+// normalizeOpenAIUsage converts OpenAI's total input count and optional cache
12+// detail into disjoint usage buckets. Malformed detail must not create a
13+// negative ordinary-input count or an unsupported cache metric, so it falls
14+// back to treating the total as ordinary input.
15+func normalizeOpenAIUsage(inputTokens, outputTokens int, details *openAIInputTokensDetails) Usage {
16+ if inputTokens < 0 {
17+ inputTokens = 0
18+ }
19+ if outputTokens < 0 {
20+ outputTokens = 0
21+ }
22+
23+ usage := Usage{InputTokens: inputTokens, OutputTokens: outputTokens}
24+ if details == nil || details.CachedTokens < 0 || details.CacheWriteTokens < 0 {
25+ return usage
26+ }
27+ if details.CachedTokens+details.CacheWriteTokens > inputTokens {
28+ return usage
29+ }
30+
31+ usage.InputTokens -= details.CachedTokens + details.CacheWriteTokens
32+ usage.CacheReadTokens = details.CachedTokens
33+ usage.CacheWriteTokens = details.CacheWriteTokens
34+ usage.CacheMeasuredPromptTokens = inputTokens
35+ usage.CacheMeasuredReadTokens = details.CachedTokens
36+ return usage
37+}
M packages/provider/provider.go +60 -26
@@ -20,6 +20,9 @@ const (
2020 RoleUser Role = "user"
2121 RoleAssistant Role = "assistant"
2222 RoleTool Role = "tool"
23+ // RoleDeveloper is host-authored provider context. Core persists it for
24+ // replay but presentation layers must not render it as ordinary chat.
25+ RoleDeveloper Role = "developer"
2326 )
2427
2528 // Content is a block inside a Message. One of TextBlock, ImageBlock,
@@ -171,13 +174,19 @@ func activatedToolNames(messages []Message) map[string]bool {
171174
172175 // Usage aggregates token counts and cost for a turn.
173176 type Usage struct {
174- InputTokens int `json:"input_tokens"`
175- OutputTokens int `json:"output_tokens"`
176- ReasoningTokens int `json:"reasoning_tokens"`
177- ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178- CacheReadTokens int `json:"cache_read_tokens"`
179- CacheWriteTokens int `json:"cache_write_tokens"`
180- CostUSD float64 `json:"cost_usd"`
177+ InputTokens int `json:"input_tokens"`
178+ OutputTokens int `json:"output_tokens"`
179+ ReasoningTokens int `json:"reasoning_tokens"`
180+ ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
181+ CacheReadTokens int `json:"cache_read_tokens"`
182+ CacheWriteTokens int `json:"cache_write_tokens"`
183+ // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the
184+ // portion of the prompt for which the provider explicitly reported cache
185+ // details. Zero values mean cache details were unavailable, including in
186+ // historical session rows.
187+ CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"`
188+ CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"`
189+ CostUSD float64 `json:"cost_usd"`
181190 }
182191
183192 // PromptTokens returns all input tokens, regardless of cache disposition.
@@ -185,28 +194,30 @@ func (u Usage) PromptTokens() int {
185194 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186195 }
187196
188-// CacheHitRatio returns the share of prompt tokens served from cache. The
189-// boolean is false when the provider reported no cache reads, which avoids
190-// presenting an unsupported cache metric as a zero-percent hit rate.
197+// CacheHitRatio returns the share of provider-measured prompt tokens served
198+// from cache. The boolean is false when cache details were unavailable, which
199+// keeps historical or unsupported usage distinct from a known zero-percent
200+// cache hit rate.
191201 func (u Usage) CacheHitRatio() (float64, bool) {
192- total := u.PromptTokens()
193- if total <= 0 || u.CacheReadTokens <= 0 {
202+ if u.CacheMeasuredPromptTokens <= 0 {
194203 return 0, false
195204 }
196- return float64(u.CacheReadTokens) / float64(total), true
205+ return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true
197206 }
198207
199208 // Add returns u plus v. A reasoning total is known only when both
200209 // component usage reports include a separate reasoning-token count.
201210 func (u Usage) Add(v Usage) Usage {
202211 return Usage{
203- InputTokens: u.InputTokens + v.InputTokens,
204- OutputTokens: u.OutputTokens + v.OutputTokens,
205- ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
206- ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
207- CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
208- CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
209- CostUSD: u.CostUSD + v.CostUSD,
212+ InputTokens: u.InputTokens + v.InputTokens,
213+ OutputTokens: u.OutputTokens + v.OutputTokens,
214+ ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
215+ ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
216+ CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
217+ CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
218+ CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens,
219+ CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens,
220+ CostUSD: u.CostUSD + v.CostUSD,
210221 }
211222 }
212223
@@ -290,6 +301,27 @@ type RequestAttemptIDLifecycle interface {
290301 RequestAttemptID(requestID string, attempt, maxAttempts int)
291302 }
292303
304+// CacheDiagnostics contains only allowlisted cache-routing state. It must not
305+// contain prompt text, durable identities, request bodies, or credentials.
306+type CacheDiagnostics struct {
307+ Eligible bool
308+ Mode string
309+ Transport string
310+ Continuation string
311+}
312+
313+// RequestCacheDiagnosticsLifecycle receives sanitized cache-routing state when
314+// an adapter can determine it. It is optional to preserve existing clients.
315+type RequestCacheDiagnosticsLifecycle interface {
316+ CacheDiagnostics(CacheDiagnostics)
317+}
318+
319+func ReportCacheDiagnostics(lifecycle RequestLifecycle, diagnostics CacheDiagnostics) {
320+ if withDiagnostics, ok := lifecycle.(RequestCacheDiagnosticsLifecycle); ok {
321+ withDiagnostics.CacheDiagnostics(diagnostics)
322+ }
323+}
324+
293325 // RequestFailureReason is an allowlisted request-failure category. It is safe
294326 // to persist; raw provider errors and response bodies are not.
295327 type RequestFailureReason string
@@ -312,13 +344,15 @@ type RequestFailureLifecycle interface {
312344 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)
313345 }
314346
315-// RequestContext identifies one logical agent session and one accepted turn.
316-// Values are opaque provider-neutral correlation IDs. Providers may translate
317-// them into cache controls or session-affinity fields, but must not replace
318-// them with transport-attempt IDs.
347+// RequestContext identifies the cache affinity, conversation thread, and
348+// accepted turn for one logical agent request. Values are opaque
349+// provider-neutral correlation IDs. Providers may translate them into cache
350+// controls or routing fields, but must not replace them with transport-attempt
351+// IDs.
319352 type RequestContext struct {
320- SessionID string
321- TurnID string
353+ CacheSessionID string
354+ ThreadID string
355+ TurnID string
322356 }
323357
324358 // Request is a single LLM call.
@@ -20,6 +20,9 @@ const (
20 RoleUser Role = "user"20 RoleUser Role = "user"
21 RoleAssistant Role = "assistant"21 RoleAssistant Role = "assistant"
22 RoleTool Role = "tool"22 RoleTool Role = "tool"
23+ // RoleDeveloper is host-authored provider context. Core persists it for
24+ // replay but presentation layers must not render it as ordinary chat.
25+ RoleDeveloper Role = "developer"
23 )26 )
24 27
25 // Content is a block inside a Message. One of TextBlock, ImageBlock,28 // Content is a block inside a Message. One of TextBlock, ImageBlock,
@@ -171,13 +174,19 @@ func activatedToolNames(messages []Message) map[string]bool {
171 174
172 // Usage aggregates token counts and cost for a turn.175 // Usage aggregates token counts and cost for a turn.
173 type Usage struct {176 type Usage struct {
174- InputTokens int `json:"input_tokens"`177+ InputTokens int `json:"input_tokens"`
175- OutputTokens int `json:"output_tokens"`178+ OutputTokens int `json:"output_tokens"`
176- ReasoningTokens int `json:"reasoning_tokens"`179+ ReasoningTokens int `json:"reasoning_tokens"`
177- ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`180+ ReasoningTokensKnown bool `json:"reasoning_tokens_known,omitempty"`
178- CacheReadTokens int `json:"cache_read_tokens"`181+ CacheReadTokens int `json:"cache_read_tokens"`
179- CacheWriteTokens int `json:"cache_write_tokens"`182+ CacheWriteTokens int `json:"cache_write_tokens"`
180- CostUSD float64 `json:"cost_usd"`183+ // CacheMeasuredPromptTokens and CacheMeasuredReadTokens describe the
184+ // portion of the prompt for which the provider explicitly reported cache
185+ // details. Zero values mean cache details were unavailable, including in
186+ // historical session rows.
187+ CacheMeasuredPromptTokens int `json:"cache_measured_prompt_tokens,omitempty"`
188+ CacheMeasuredReadTokens int `json:"cache_measured_read_tokens,omitempty"`
189+ CostUSD float64 `json:"cost_usd"`
181 }190 }
182 191
183 // PromptTokens returns all input tokens, regardless of cache disposition.192 // PromptTokens returns all input tokens, regardless of cache disposition.
@@ -185,28 +194,30 @@ func (u Usage) PromptTokens() int {
185 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens194 return u.InputTokens + u.CacheReadTokens + u.CacheWriteTokens
186 }195 }
187 196
188-// CacheHitRatio returns the share of prompt tokens served from cache. The197+// CacheHitRatio returns the share of provider-measured prompt tokens served
189-// boolean is false when the provider reported no cache reads, which avoids198+// from cache. The boolean is false when cache details were unavailable, which
190-// presenting an unsupported cache metric as a zero-percent hit rate.199+// keeps historical or unsupported usage distinct from a known zero-percent
200+// cache hit rate.
191 func (u Usage) CacheHitRatio() (float64, bool) {201 func (u Usage) CacheHitRatio() (float64, bool) {
192- total := u.PromptTokens()202+ if u.CacheMeasuredPromptTokens <= 0 {
193- if total <= 0 || u.CacheReadTokens <= 0 {
194 return 0, false203 return 0, false
195 }204 }
196- return float64(u.CacheReadTokens) / float64(total), true205+ return float64(u.CacheMeasuredReadTokens) / float64(u.CacheMeasuredPromptTokens), true
197 }206 }
198 207
199 // Add returns u plus v. A reasoning total is known only when both208 // Add returns u plus v. A reasoning total is known only when both
200 // component usage reports include a separate reasoning-token count.209 // component usage reports include a separate reasoning-token count.
201 func (u Usage) Add(v Usage) Usage {210 func (u Usage) Add(v Usage) Usage {
202 return Usage{211 return Usage{
203- InputTokens: u.InputTokens + v.InputTokens,212+ InputTokens: u.InputTokens + v.InputTokens,
204- OutputTokens: u.OutputTokens + v.OutputTokens,213+ OutputTokens: u.OutputTokens + v.OutputTokens,
205- ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,214+ ReasoningTokens: u.ReasoningTokens + v.ReasoningTokens,
206- ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,215+ ReasoningTokensKnown: u.ReasoningTokensKnown && v.ReasoningTokensKnown,
207- CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,216+ CacheReadTokens: u.CacheReadTokens + v.CacheReadTokens,
208- CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,217+ CacheWriteTokens: u.CacheWriteTokens + v.CacheWriteTokens,
209- CostUSD: u.CostUSD + v.CostUSD,218+ CacheMeasuredPromptTokens: u.CacheMeasuredPromptTokens + v.CacheMeasuredPromptTokens,
219+ CacheMeasuredReadTokens: u.CacheMeasuredReadTokens + v.CacheMeasuredReadTokens,
220+ CostUSD: u.CostUSD + v.CostUSD,
210 }221 }
211 }222 }
212 223
@@ -290,6 +301,27 @@ type RequestAttemptIDLifecycle interface {
290 RequestAttemptID(requestID string, attempt, maxAttempts int)301 RequestAttemptID(requestID string, attempt, maxAttempts int)
291 }302 }
292 303
304+// CacheDiagnostics contains only allowlisted cache-routing state. It must not
305+// contain prompt text, durable identities, request bodies, or credentials.
306+type CacheDiagnostics struct {
307+ Eligible bool
308+ Mode string
309+ Transport string
310+ Continuation string
311+}
312+
313+// RequestCacheDiagnosticsLifecycle receives sanitized cache-routing state when
314+// an adapter can determine it. It is optional to preserve existing clients.
315+type RequestCacheDiagnosticsLifecycle interface {
316+ CacheDiagnostics(CacheDiagnostics)
317+}
318+
319+func ReportCacheDiagnostics(lifecycle RequestLifecycle, diagnostics CacheDiagnostics) {
320+ if withDiagnostics, ok := lifecycle.(RequestCacheDiagnosticsLifecycle); ok {
321+ withDiagnostics.CacheDiagnostics(diagnostics)
322+ }
323+}
324+
293 // RequestFailureReason is an allowlisted request-failure category. It is safe325 // RequestFailureReason is an allowlisted request-failure category. It is safe
294 // to persist; raw provider errors and response bodies are not.326 // to persist; raw provider errors and response bodies are not.
295 type RequestFailureReason string327 type RequestFailureReason string
@@ -312,13 +344,15 @@ type RequestFailureLifecycle interface {
312 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)344 RequestFailed(attempt, maxAttempts int, reason RequestFailureReason, terminal bool)
313 }345 }
314 346
315-// RequestContext identifies one logical agent session and one accepted turn.347+// RequestContext identifies the cache affinity, conversation thread, and
316-// Values are opaque provider-neutral correlation IDs. Providers may translate348+// accepted turn for one logical agent request. Values are opaque
317-// them into cache controls or session-affinity fields, but must not replace349+// provider-neutral correlation IDs. Providers may translate them into cache
318-// them with transport-attempt IDs.350+// controls or routing fields, but must not replace them with transport-attempt
351+// IDs.
319 type RequestContext struct {352 type RequestContext struct {
320- SessionID string353+ CacheSessionID string
321- TurnID string354+ ThreadID string
355+ TurnID string
322 }356 }
323 357
324 // Request is a single LLM call.358 // Request is a single LLM call.
M packages/provider/provider_test.go +23 -4
@@ -12,7 +12,7 @@ import (
1212 )
1313
1414 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15- u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}
15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000}
1616 if got := u.PromptTokens(); got != 210_000 {
1717 t.Fatalf("prompt tokens = %d, want 210000", got)
1818 }
@@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
2222 }
2323 }
2424
25-func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {
26- if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {
27- t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)
25+func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) {
26+ knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000}
27+ if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 {
28+ t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok)
29+ }
30+
31+ historicalUnknown := Usage{InputTokens: 84_000}
32+ if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 {
33+ t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok)
34+ }
35+}
36+
37+func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) {
38+ measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40}
39+ unknown := Usage{InputTokens: 200, CacheReadTokens: 100}
40+
41+ got := measured.Add(unknown)
42+ if got.CacheMeasuredPromptTokens != 100 {
43+ t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens)
44+ }
45+ if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 {
46+ t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok)
2847 }
2948 }
3049
@@ -12,7 +12,7 @@ import (
12 )12 )
13 13
14 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {14 func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
15- u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000}15+ u := Usage{InputTokens: 84_000, CacheReadTokens: 123_000, CacheWriteTokens: 3_000, CacheMeasuredPromptTokens: 210_000, CacheMeasuredReadTokens: 123_000}
16 if got := u.PromptTokens(); got != 210_000 {16 if got := u.PromptTokens(); got != 210_000 {
17 t.Fatalf("prompt tokens = %d, want 210000", got)17 t.Fatalf("prompt tokens = %d, want 210000", got)
18 }18 }
@@ -22,9 +22,28 @@ func TestUsageCacheHitRatioIncludesAllPromptTokens(t *testing.T) {
22 }22 }
23 }23 }
24 24
25-func TestUsageCacheHitRatioUnavailableWithoutCacheReads(t *testing.T) {25+func TestUsageCacheHitRatioDistinguishesKnownZeroFromHistoricalUnknown(t *testing.T) {
26- if ratio, ok := (Usage{InputTokens: 84_000}).CacheHitRatio(); ok || ratio != 0 {26+ knownMiss := Usage{InputTokens: 84_000, CacheMeasuredPromptTokens: 84_000}
27- t.Fatalf("cache hit ratio = %v, %v, want 0, false", ratio, ok)27+ if ratio, ok := knownMiss.CacheHitRatio(); !ok || ratio != 0 {
28+ t.Fatalf("known cache miss ratio = %v, %v, want 0, true", ratio, ok)
29+ }
30+
31+ historicalUnknown := Usage{InputTokens: 84_000}
32+ if ratio, ok := historicalUnknown.CacheHitRatio(); ok || ratio != 0 {
33+ t.Fatalf("historical cache ratio = %v, %v, want 0, false", ratio, ok)
34+ }
35+}
36+
37+func TestUsageAddPreservesOnlyMeasuredCacheDenominators(t *testing.T) {
38+ measured := Usage{InputTokens: 60, CacheReadTokens: 40, CacheMeasuredPromptTokens: 100, CacheMeasuredReadTokens: 40}
39+ unknown := Usage{InputTokens: 200, CacheReadTokens: 100}
40+
41+ got := measured.Add(unknown)
42+ if got.CacheMeasuredPromptTokens != 100 {
43+ t.Fatalf("measured cache denominator = %d, want 100", got.CacheMeasuredPromptTokens)
44+ }
45+ if ratio, ok := got.CacheHitRatio(); !ok || math.Abs(ratio-0.4) > 1e-9 {
46+ t.Fatalf("cache hit ratio = %v, %v, want 0.4, true", ratio, ok)
28 }47 }
29 }48 }
30 49
M packages/provider/reasoning_usage_test.go +58 -0
@@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) {
2828 }
2929 }
3030
31+func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) {
32+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ w.Header().Set("content-type", "text/event-stream")
34+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`)
35+ fmt.Fprintln(w)
36+ fmt.Fprintln(w, "data: [DONE]")
37+ fmt.Fprintln(w)
38+ }))
39+ defer srv.Close()
40+
41+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ usage := usageFromEvents(events)
46+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
47+ t.Fatalf("usage = %+v", usage)
48+ }
49+}
50+
51+func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ w.Header().Set("content-type", "text/event-stream")
54+ fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`)
55+ fmt.Fprintln(w)
56+ }))
57+ defer srv.Close()
58+
59+ events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"})
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ usage := usageFromEvents(events)
64+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
65+ t.Fatalf("usage = %+v", usage)
66+ }
67+}
68+
69+func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("content-type", "text/event-stream")
72+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`)
73+ fmt.Fprintln(w)
74+ fmt.Fprintln(w, "data: [DONE]")
75+ fmt.Fprintln(w)
76+ }))
77+ defer srv.Close()
78+
79+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ usage := usageFromEvents(events)
84+ if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 {
85+ t.Fatalf("malformed cache usage = %+v", usage)
86+ }
87+}
88+
3189 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {
3290 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
3391 w.Header().Set("content-type", "text/event-stream")
@@ -28,6 +28,64 @@ func TestOpenAIReportsReasoningTokens(t *testing.T) {
28 }28 }
29 }29 }
30 30
31+func TestOpenAINormalizesMeasuredCacheUsage(t *testing.T) {
32+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33+ w.Header().Set("content-type", "text/event-stream")
34+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}`)
35+ fmt.Fprintln(w)
36+ fmt.Fprintln(w, "data: [DONE]")
37+ fmt.Fprintln(w)
38+ }))
39+ defer srv.Close()
40+
41+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
42+ if err != nil {
43+ t.Fatal(err)
44+ }
45+ usage := usageFromEvents(events)
46+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
47+ t.Fatalf("usage = %+v", usage)
48+ }
49+}
50+
51+func TestOpenAIResponsesNormalizesMeasuredCacheUsage(t *testing.T) {
52+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
53+ w.Header().Set("content-type", "text/event-stream")
54+ fmt.Fprintln(w, `data: {"type":"response.completed","response":{"status":"completed","usage":{"input_tokens":100,"output_tokens":9,"input_tokens_details":{"cached_tokens":70,"cache_write_tokens":10}}}}`)
55+ fmt.Fprintln(w)
56+ }))
57+ defer srv.Close()
58+
59+ events, err := NewOpenAICodex("test", "account", srv.URL).Stream(context.Background(), Request{Model: "gpt-5.5"})
60+ if err != nil {
61+ t.Fatal(err)
62+ }
63+ usage := usageFromEvents(events)
64+ if usage.InputTokens != 20 || usage.CacheReadTokens != 70 || usage.CacheWriteTokens != 10 || usage.CacheMeasuredPromptTokens != 100 || usage.CacheMeasuredReadTokens != 70 {
65+ t.Fatalf("usage = %+v", usage)
66+ }
67+}
68+
69+func TestOpenAIRejectsMalformedCacheUsageDetails(t *testing.T) {
70+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
71+ w.Header().Set("content-type", "text/event-stream")
72+ fmt.Fprintln(w, `data: {"choices":[{"delta":{"content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":100,"completion_tokens":9,"prompt_tokens_details":{"cached_tokens":70,"cache_write_tokens":40}}}`)
73+ fmt.Fprintln(w)
74+ fmt.Fprintln(w, "data: [DONE]")
75+ fmt.Fprintln(w)
76+ }))
77+ defer srv.Close()
78+
79+ events, err := NewOpenAI("test", srv.URL).Stream(context.Background(), Request{Model: "gpt-test"})
80+ if err != nil {
81+ t.Fatal(err)
82+ }
83+ usage := usageFromEvents(events)
84+ if usage.InputTokens != 100 || usage.CacheReadTokens != 0 || usage.CacheWriteTokens != 0 || usage.CacheMeasuredPromptTokens != 0 || usage.CacheMeasuredReadTokens != 0 {
85+ t.Fatalf("malformed cache usage = %+v", usage)
86+ }
87+}
88+
31 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {89 func TestOpenAIResponsesReportsReasoningTokens(t *testing.T) {
32 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {90 srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
33 w.Header().Set("content-type", "text/event-stream")91 w.Header().Set("content-type", "text/event-stream")
M packages/tui/statusbar_test.go +13 -2
@@ -10,7 +10,7 @@ import (
1010 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
1111 lines := StatusBar(StatusBarParams{
1212 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},
13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
1414 ContextUsed: 45_152, ContextMax: 272_000,
1515 })
1616 plain := stripANSI(strings.Join(lines, "\n"))
@@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) {
1919 }
2020 }
2121
22+func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
23+ lines := StatusBar(StatusBarParams{
24+ Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
25+ Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
26+ })
27+ plain := stripANSI(strings.Join(lines, "\n"))
28+ if strings.Contains(plain, "C") {
29+ t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
30+ }
31+}
32+
2233 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
2334 const cols = 30
2435 lines := StatusBar(StatusBarParams{
2536 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},
37+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
2738 ContextUsed: 209_000, ContextMax: 272_000,
2839 })
2940 plain := stripANSI(strings.Join(lines, "\n"))
@@ -10,7 +10,7 @@ import (
10 func TestStatusBarShowsCacheHitRatio(t *testing.T) {10 func TestStatusBarShowsCacheHitRatio(t *testing.T) {
11 lines := StatusBar(StatusBarParams{11 lines := StatusBar(StatusBarParams{
12 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,12 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: 200,
13- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000},13+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheMeasuredPromptTokens: 207_000, CacheMeasuredReadTokens: 123_000},
14 ContextUsed: 45_152, ContextMax: 272_000,14 ContextUsed: 45_152, ContextMax: 272_000,
15 })15 })
16 plain := stripANSI(strings.Join(lines, "\n"))16 plain := stripANSI(strings.Join(lines, "\n"))
@@ -19,11 +19,22 @@ func TestStatusBarShowsCacheHitRatio(t *testing.T) {
19 }19 }
20 }20 }
21 21
22+func TestStatusBarOmitsCacheRatioWhenCacheDetailsAreUnavailable(t *testing.T) {
23+ lines := StatusBar(StatusBarParams{
24+ Theme: Dark, Provider: "compatible", Model: "model", Cols: 200,
25+ Usage: provider.Usage{InputTokens: 84_000, CacheReadTokens: 123_000},
26+ })
27+ plain := stripANSI(strings.Join(lines, "\n"))
28+ if strings.Contains(plain, "C") {
29+ t.Fatalf("status bar = %q, want no cache ratio without measured details", plain)
30+ }
31+}
32+
22 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {33 func TestStatusBarWrapsCacheStatsWithinNarrowWidth(t *testing.T) {
23 const cols = 3034 const cols = 30
24 lines := StatusBar(StatusBarParams{35 lines := StatusBar(StatusBarParams{
25 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,36 Theme: Dark, Provider: "openai-codex", Model: "gpt-test", Cols: cols,
26- Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CostUSD: 0.525},37+ Usage: provider.Usage{InputTokens: 84_000, OutputTokens: 1_500, CacheReadTokens: 123_000, CacheWriteTokens: 2_000, CacheMeasuredPromptTokens: 209_000, CacheMeasuredReadTokens: 123_000, CostUSD: 0.525},
27 ContextUsed: 209_000, ContextMax: 272_000,38 ContextUsed: 209_000, ContextMax: 272_000,
28 })39 })
29 plain := stripANSI(strings.Join(lines, "\n"))40 plain := stripANSI(strings.Join(lines, "\n"))

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