detached c21eb9c

fix(provider): verify cache continuation boundaries

commit
c21eb9cf092c942d66fbbdf4550dabfaea705cd8
author
brice · · 2026-08-18 09:12 UTC
parent
808bb65
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +278 -76
M docs/providers.md +5 -2
@@ -158,7 +158,9 @@ identity but retain distinct thread identities. The OpenAI/Codex Responses
158158 route uses the root identity as `prompt_cache_key`; declared Codex routes also
159159 receive their documented session and thread routing headers. A generic
160160 OpenAI-compatible endpoint never receives these extensions merely because it
161-accepts an OpenAI-shaped request.
161+accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache
162+diagnostics (`eligible`, `mode`, `transport`, and `continuation`). These
163+records never include prompts, durable IDs, request bodies, or credentials.
162164
163165 When a provider reports cache detail, zut displays cache-read hit rate as
164166 `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed
@@ -174,7 +176,8 @@ the Responses API WebSocket mode for the OpenAI Responses route. It keeps one
174176 connection per conversation thread, sends one `response.create` at a time,
175177 and continues the most recent completed response with `previous_response_id`
176178 only when the complete response-context configuration and prior input prefix
177-match exactly. Compatible continuations send only new tool or user input.
179+match exactly, including the completed assistant output that precedes a new
180+suffix. Compatible continuations send only new tool or user input.
178181 The request uses `store:false`; its only provider-specific handshake header is
179182 `Authorization: Bearer ...`, never the
180183 ChatGPT/Codex account, routing, or session headers.
@@ -158,7 +158,9 @@ identity but retain distinct thread identities. The OpenAI/Codex Responses
158 route uses the root identity as `prompt_cache_key`; declared Codex routes also158 route uses the root identity as `prompt_cache_key`; declared Codex routes also
159 receive their documented session and thread routing headers. A generic159 receive their documented session and thread routing headers. A generic
160 OpenAI-compatible endpoint never receives these extensions merely because it160 OpenAI-compatible endpoint never receives these extensions merely because it
161-accepts an OpenAI-shaped request.161+accepts an OpenAI-shaped request. JSON event mode also reports sanitized cache
162+diagnostics (`eligible`, `mode`, `transport`, and `continuation`). These
163+records never include prompts, durable IDs, request bodies, or credentials.
162 164
163 When a provider reports cache detail, zut displays cache-read hit rate as165 When a provider reports cache detail, zut displays cache-read hit rate as
164 `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed166 `cache reads / cache-reporting prompt input`. A shown `0%` is a confirmed
@@ -174,7 +176,8 @@ the Responses API WebSocket mode for the OpenAI Responses route. It keeps one
174 connection per conversation thread, sends one `response.create` at a time,176 connection per conversation thread, sends one `response.create` at a time,
175 and continues the most recent completed response with `previous_response_id`177 and continues the most recent completed response with `previous_response_id`
176 only when the complete response-context configuration and prior input prefix178 only when the complete response-context configuration and prior input prefix
177-match exactly. Compatible continuations send only new tool or user input.179+match exactly, including the completed assistant output that precedes a new
180+suffix. Compatible continuations send only new tool or user input.
178 The request uses `store:false`; its only provider-specific handshake header is181 The request uses `store:false`; its only provider-specific handshake header is
179 `Authorization: Bearer ...`, never the182 `Authorization: Bearer ...`, never the
180 ChatGPT/Codex account, routing, or session headers.183 ChatGPT/Codex account, routing, or session headers.
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; 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; 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/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 +24 -18
@@ -92,12 +92,14 @@ type Image struct {
9292
9393 // Usage is per-turn or cumulative token / cost counts.
9494 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"`
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+ CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"`
101+ CacheMeasuredRead int `json:"cache_measured_read,omitempty"`
102+ CostUSD float64 `json:"cost_usd"`
101103 }
102104
103105 // State is a snapshot of the runtime's current state.
@@ -182,20 +184,24 @@ func toEvent(ev core.AgentEvent) Event {
182184 out.Result = convertContent(e.Result.Content)
183185 case core.EvUsage:
184186 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,
187+ Input: e.Usage.InputTokens,
188+ Output: e.Usage.OutputTokens,
189+ Reasoning: sdkReasoningTokens(e.Usage),
190+ CacheRead: e.Usage.CacheReadTokens,
191+ CacheWrite: e.Usage.CacheWriteTokens,
192+ CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens,
193+ CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens,
194+ CostUSD: e.Usage.CostUSD,
191195 }
192196 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,
197+ Input: e.Cumulative.InputTokens,
198+ Output: e.Cumulative.OutputTokens,
199+ Reasoning: sdkReasoningTokens(e.Cumulative),
200+ CacheRead: e.Cumulative.CacheReadTokens,
201+ CacheWrite: e.Cumulative.CacheWriteTokens,
202+ CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens,
203+ CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens,
204+ CostUSD: e.Cumulative.CostUSD,
199205 }
200206 case core.EvTurnEnd:
201207 out.Stop = string(e.Stop)
@@ -92,12 +92,14 @@ type Image struct {
92 92
93 // Usage is per-turn or cumulative token / cost counts.93 // Usage is per-turn or cumulative token / cost counts.
94 type Usage struct {94 type Usage struct {
95- Input int `json:"input"`95+ Input int `json:"input"`
96- Output int `json:"output"`96+ Output int `json:"output"`
97- Reasoning *int `json:"reasoning"`97+ Reasoning *int `json:"reasoning"`
98- CacheRead int `json:"cache_read"`98+ CacheRead int `json:"cache_read"`
99- CacheWrite int `json:"cache_write"`99+ CacheWrite int `json:"cache_write"`
100- CostUSD float64 `json:"cost_usd"`100+ CacheMeasuredPrompt int `json:"cache_measured_prompt,omitempty"`
101+ CacheMeasuredRead int `json:"cache_measured_read,omitempty"`
102+ CostUSD float64 `json:"cost_usd"`
101 }103 }
102 104
103 // State is a snapshot of the runtime's current state.105 // State is a snapshot of the runtime's current state.
@@ -182,20 +184,24 @@ func toEvent(ev core.AgentEvent) Event {
182 out.Result = convertContent(e.Result.Content)184 out.Result = convertContent(e.Result.Content)
183 case core.EvUsage:185 case core.EvUsage:
184 out.Usage = &Usage{186 out.Usage = &Usage{
185- Input: e.Usage.InputTokens,187+ Input: e.Usage.InputTokens,
186- Output: e.Usage.OutputTokens,188+ Output: e.Usage.OutputTokens,
187- Reasoning: sdkReasoningTokens(e.Usage),189+ Reasoning: sdkReasoningTokens(e.Usage),
188- CacheRead: e.Usage.CacheReadTokens,190+ CacheRead: e.Usage.CacheReadTokens,
189- CacheWrite: e.Usage.CacheWriteTokens,191+ CacheWrite: e.Usage.CacheWriteTokens,
190- CostUSD: e.Usage.CostUSD,192+ CacheMeasuredPrompt: e.Usage.CacheMeasuredPromptTokens,
193+ CacheMeasuredRead: e.Usage.CacheMeasuredReadTokens,
194+ CostUSD: e.Usage.CostUSD,
191 }195 }
192 out.Cumulative = &Usage{196 out.Cumulative = &Usage{
193- Input: e.Cumulative.InputTokens,197+ Input: e.Cumulative.InputTokens,
194- Output: e.Cumulative.OutputTokens,198+ Output: e.Cumulative.OutputTokens,
195- Reasoning: sdkReasoningTokens(e.Cumulative),199+ Reasoning: sdkReasoningTokens(e.Cumulative),
196- CacheRead: e.Cumulative.CacheReadTokens,200+ CacheRead: e.Cumulative.CacheReadTokens,
197- CacheWrite: e.Cumulative.CacheWriteTokens,201+ CacheWrite: e.Cumulative.CacheWriteTokens,
198- CostUSD: e.Cumulative.CostUSD,202+ CacheMeasuredPrompt: e.Cumulative.CacheMeasuredPromptTokens,
203+ CacheMeasuredRead: e.Cumulative.CacheMeasuredReadTokens,
204+ CostUSD: e.Cumulative.CostUSD,
199 }205 }
200 case core.EvTurnEnd:206 case core.EvTurnEnd:
201 out.Stop = string(e.Stop)207 out.Stop = string(e.Stop)
M packages/core/agent.go +9 -0
@@ -899,6 +899,15 @@ type requestLifecycleSink struct {
899899 lastReason RetryReason
900900 }
901901
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+
902911 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
903912 s.sink(EvRequestStarted{
904913 Provider: s.provider,
@@ -899,6 +899,15 @@ type requestLifecycleSink struct {
899 lastReason RetryReason899 lastReason RetryReason
900 }900 }
901 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+
902 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {911 func (s *requestLifecycleSink) RequestAttempt(attempt, maxAttempts int) {
903 s.sink(EvRequestStarted{912 s.sink(EvRequestStarted{
904 Provider: s.provider,913 Provider: s.provider,
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_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/provider/amazon_bedrock.go +3 -2
@@ -333,7 +333,8 @@ 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 != "" {
336+ system, messages := systemWithDeveloperContext(req)
337+ if system != "" {
337338 sysBlock := map[string]interface{}{"text": system}
338339 if caching {
339340 // Append cachePoint after the system text so the stable system
@@ -356,7 +357,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
356357 if out.InferenceConfig.MaxTokens == 0 {
357358 out.InferenceConfig.MaxTokens = 4096
358359 }
359- for _, m := range normalizeBedrockToolResults(req.Messages) {
360+ for _, m := range normalizeBedrockToolResults(messages) {
360361 role := string(m.Role)
361362 if role == "tool" {
362363 role = "user"
@@ -333,7 +333,8 @@ 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+ system, messages := systemWithDeveloperContext(req)
337+ if system != "" {
337 sysBlock := map[string]interface{}{"text": system}338 sysBlock := map[string]interface{}{"text": system}
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
@@ -356,7 +357,7 @@ func (c *bedrockClient) buildRequest(req Request) (*bedrockRequest, error) {
356 if out.InferenceConfig.MaxTokens == 0 {357 if out.InferenceConfig.MaxTokens == 0 {
357 out.InferenceConfig.MaxTokens = 4096358 out.InferenceConfig.MaxTokens = 4096
358 }359 }
359- for _, m := range normalizeBedrockToolResults(req.Messages) {360+ for _, m := range normalizeBedrockToolResults(messages) {
360 role := string(m.Role)361 role := string(m.Role)
361 if role == "tool" {362 if role == "tool" {
362 role = "user"363 role = "user"
M packages/provider/anthropic.go +5 -5
@@ -246,7 +246,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
246246 out.Temperature = req.Temperature
247247 }
248248
249- system := req.SystemPrompt()
249+ system, 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
@@ -318,7 +318,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
318318 }
319319 }
320320
321- for _, t := range activeToolDefinitions(req.Tools, req.Messages) {
321+ for _, t := range activeToolDefinitions(req.Tools, messages) {
322322 name := t.Name
323323 if c.oauthTok != "" {
324324 name = toClaudeCodeToolName(name)
@@ -353,11 +353,11 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
353353 // emitting them separately keeps each message bit-stable across
354354 // turns, so the cache prefix matches for the entire history up
355355 // to the newest block.
356- req.Messages = RepairOrphanedToolResults(req.Messages)
357- for _, msg := range req.Messages {
356+ messages = RepairOrphanedToolResults(messages)
357+ for _, msg := range messages {
358358 renameTools := c.oauthTok != ""
359359 switch msg.Role {
360- case RoleDeveloper, RoleUser:
360+ case RoleUser:
361361 out.Messages = append(out.Messages, anthMessage{
362362 Role: "user",
363363 Content: convertAnthContent(msg.Content, renameTools),
@@ -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+ system, 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
@@ -318,7 +318,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
318 }318 }
319 }319 }
320 320
321- for _, t := range activeToolDefinitions(req.Tools, req.Messages) {321+ for _, t := range activeToolDefinitions(req.Tools, messages) {
322 name := t.Name322 name := t.Name
323 if c.oauthTok != "" {323 if c.oauthTok != "" {
324 name = toClaudeCodeToolName(name)324 name = toClaudeCodeToolName(name)
@@ -353,11 +353,11 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
353 // emitting them separately keeps each message bit-stable across353 // emitting them separately keeps each message bit-stable across
354 // turns, so the cache prefix matches for the entire history up354 // turns, so the cache prefix matches for the entire history up
355 // to the newest block.355 // to the newest block.
356- req.Messages = RepairOrphanedToolResults(req.Messages)356+ messages = RepairOrphanedToolResults(messages)
357- for _, msg := range req.Messages {357+ for _, msg := range messages {
358 renameTools := c.oauthTok != ""358 renameTools := c.oauthTok != ""
359 switch msg.Role {359 switch msg.Role {
360- case RoleDeveloper, RoleUser:360+ case RoleUser:
361 out.Messages = append(out.Messages, anthMessage{361 out.Messages = append(out.Messages, anthMessage{
362 Role: "user",362 Role: "user",
363 Content: convertAnthContent(msg.Content, renameTools),363 Content: convertAnthContent(msg.Content, renameTools),
A packages/provider/developer_context.go +30 -0
@@ -0,0 +1,30 @@
1+package provider
2+
3+import "strings"
4+
5+// systemWithDeveloperContext projects host-authored developer messages onto
6+// providers that expose only a top-level system instruction. OpenAI adapters
7+// preserve RoleDeveloper as a native developer input instead.
8+func systemWithDeveloperContext(req Request) (string, []Message) {
9+ messages := make([]Message, 0, len(req.Messages))
10+ context := make([]string, 0)
11+ for _, message := range req.Messages {
12+ if message.Role != RoleDeveloper {
13+ messages = append(messages, message)
14+ continue
15+ }
16+ for _, block := range message.Content {
17+ if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" {
18+ context = append(context, text.Text)
19+ }
20+ }
21+ }
22+ if len(context) == 0 {
23+ return req.SystemPrompt(), messages
24+ }
25+ system := strings.TrimSpace(req.SystemPrompt())
26+ if system != "" {
27+ system += "\n\n"
28+ }
29+ return system + strings.Join(context, "\n\n"), messages
30+}
@@ -0,0 +1,30 @@
1+package provider
2+
3+import "strings"
4+
5+// systemWithDeveloperContext projects host-authored developer messages onto
6+// providers that expose only a top-level system instruction. OpenAI adapters
7+// preserve RoleDeveloper as a native developer input instead.
8+func systemWithDeveloperContext(req Request) (string, []Message) {
9+ messages := make([]Message, 0, len(req.Messages))
10+ context := make([]string, 0)
11+ for _, message := range req.Messages {
12+ if message.Role != RoleDeveloper {
13+ messages = append(messages, message)
14+ continue
15+ }
16+ for _, block := range message.Content {
17+ if text, ok := block.(TextBlock); ok && strings.TrimSpace(text.Text) != "" {
18+ context = append(context, text.Text)
19+ }
20+ }
21+ }
22+ if len(context) == 0 {
23+ return req.SystemPrompt(), messages
24+ }
25+ system := strings.TrimSpace(req.SystemPrompt())
26+ if system != "" {
27+ system += "\n\n"
28+ }
29+ return system + strings.Join(context, "\n\n"), messages
30+}
A packages/provider/developer_context_test.go +35 -0
@@ -0,0 +1,35 @@
1+package provider
2+
3+import "testing"
4+
5+func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) {
6+ system, messages := systemWithDeveloperContext(Request{
7+ System: "stable instructions",
8+ Messages: []Message{
9+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
10+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
11+ },
12+ })
13+ if system != "stable instructions\n\ndynamic host context" {
14+ t.Fatalf("system = %q", system)
15+ }
16+ if len(messages) != 1 || messages[0].Role != RoleUser {
17+ t.Fatalf("messages = %#v, want only the user task", messages)
18+ }
19+}
20+
21+func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) {
22+ system, messages := systemWithDeveloperContext(Request{
23+ System: "stable instructions",
24+ SystemContext: "legacy host context",
25+ Messages: []Message{
26+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
27+ },
28+ })
29+ if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" {
30+ t.Fatalf("system = %q", system)
31+ }
32+ if len(messages) != 0 {
33+ t.Fatalf("messages = %#v, want no developer message", messages)
34+ }
35+}
@@ -0,0 +1,35 @@
1+package provider
2+
3+import "testing"
4+
5+func TestSystemWithDeveloperContextPromotesOnlyDeveloperText(t *testing.T) {
6+ system, messages := systemWithDeveloperContext(Request{
7+ System: "stable instructions",
8+ Messages: []Message{
9+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
10+ {Role: RoleUser, Content: []Content{TextBlock{Text: "user task"}}},
11+ },
12+ })
13+ if system != "stable instructions\n\ndynamic host context" {
14+ t.Fatalf("system = %q", system)
15+ }
16+ if len(messages) != 1 || messages[0].Role != RoleUser {
17+ t.Fatalf("messages = %#v, want only the user task", messages)
18+ }
19+}
20+
21+func TestSystemWithDeveloperContextKeepsLegacySystemContext(t *testing.T) {
22+ system, messages := systemWithDeveloperContext(Request{
23+ System: "stable instructions",
24+ SystemContext: "legacy host context",
25+ Messages: []Message{
26+ {Role: RoleDeveloper, Content: []Content{TextBlock{Text: "dynamic host context"}}},
27+ },
28+ })
29+ if system != "stable instructions\n\nlegacy host context\n\ndynamic host context" {
30+ t.Fatalf("system = %q", system)
31+ }
32+ if len(messages) != 0 {
33+ t.Fatalf("messages = %#v, want no developer message", messages)
34+ }
35+}
M packages/provider/gemini.go +6 -5
@@ -142,17 +142,18 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
142142 }
143143
144144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145+ system, messages := systemWithDeveloperContext(req)
145146 out := &gemRequest{}
146147
147148 // System prompt → systemInstruction.parts[0].text.
148- if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {
149+ if strings.TrimSpace(system) != "" {
149150 out.SystemInstruction = &gemSystemInstruction{
150151 Parts: []gemPart{{Text: system}},
151152 }
152153 }
153154
154155 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
155- activeTools := activeToolDefinitions(req.Tools, req.Messages)
156+ activeTools := activeToolDefinitions(req.Tools, messages)
156157
157158 // Convert tool defs. Gemini image-generation models reject function
158159 // declarations with "Function calling is not enabled for this model";
@@ -190,13 +191,13 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
190191 // Convert messages. When function calling is disabled for the target
191192 // model, also remove historical functionCall/functionResponse parts;
192193 // image models should receive only text/image content.
193- msgs := req.Messages
194+ msgs := messages
194195 if functionsEnabled {
195- msgs = RepairOrphanedToolResults(req.Messages)
196+ msgs = RepairOrphanedToolResults(messages)
196197 }
197198 for _, msg := range msgs {
198199 switch msg.Role {
199- case RoleDeveloper, RoleUser:
200+ case RoleUser:
200201 parts := convertGemUserParts(msg.Content)
201202 if len(parts) == 0 {
202203 continue
@@ -142,17 +142,18 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
142 }142 }
143 143
144 reasoning := ClampReasoningForModel(m, req.Reasoning)144 reasoning := ClampReasoningForModel(m, req.Reasoning)
145+ system, messages := systemWithDeveloperContext(req)
145 out := &gemRequest{}146 out := &gemRequest{}
146 147
147 // System prompt → systemInstruction.parts[0].text.148 // System prompt → systemInstruction.parts[0].text.
148- if system := req.SystemPrompt(); strings.TrimSpace(system) != "" {149+ if strings.TrimSpace(system) != "" {
149 out.SystemInstruction = &gemSystemInstruction{150 out.SystemInstruction = &gemSystemInstruction{
150 Parts: []gemPart{{Text: system}},151 Parts: []gemPart{{Text: system}},
151 }152 }
152 }153 }
153 154
154 functionsEnabled := geminiSupportsFunctionCalling(m.ID)155 functionsEnabled := geminiSupportsFunctionCalling(m.ID)
155- activeTools := activeToolDefinitions(req.Tools, req.Messages)156+ activeTools := activeToolDefinitions(req.Tools, messages)
156 157
157 // Convert tool defs. Gemini image-generation models reject function158 // Convert tool defs. Gemini image-generation models reject function
158 // declarations with "Function calling is not enabled for this model";159 // declarations with "Function calling is not enabled for this model";
@@ -190,13 +191,13 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
190 // Convert messages. When function calling is disabled for the target191 // Convert messages. When function calling is disabled for the target
191 // model, also remove historical functionCall/functionResponse parts;192 // model, also remove historical functionCall/functionResponse parts;
192 // image models should receive only text/image content.193 // image models should receive only text/image content.
193- msgs := req.Messages194+ msgs := messages
194 if functionsEnabled {195 if functionsEnabled {
195- msgs = RepairOrphanedToolResults(req.Messages)196+ msgs = RepairOrphanedToolResults(messages)
196 }197 }
197 for _, msg := range msgs {198 for _, msg := range msgs {
198 switch msg.Role {199 switch msg.Role {
199- case RoleDeveloper, RoleUser:200+ case RoleUser:
200 parts := convertGemUserParts(msg.Content)201 parts := convertGemUserParts(msg.Content)
201 if len(parts) == 0 {202 if len(parts) == 0 {
202 continue203 continue
M packages/provider/openai_codex.go +31 -13
@@ -93,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" }
9393 // ---- Responses API wire types (subset needed for zut's surface) ----
9494
9595 type codexInputText struct {
96- Type string `json:"type"` // "input_text"
97- Text string `json:"text"`
96+ Type string `json:"type"` // "input_text"
97+ Text string `json:"text"`
98+ PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
9899 }
99100
100101 type codexInputImage struct {
@@ -114,14 +115,13 @@ type codexPromptCacheOptions struct {
114115 }
115116
116117 type codexPromptCacheBreakpoint struct {
117- Type string `json:"type"`
118+ Mode string `json:"mode"`
118119 }
119120
120121 type codexInputMessage struct {
121- Type string `json:"type,omitempty"` // "message" (optional for input)
122- Role string `json:"role"`
123- Content []any `json:"content"`
124- PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
122+ Type string `json:"type,omitempty"` // "message" (optional for input)
123+ Role string `json:"role"`
124+ Content []any `json:"content"`
125125 }
126126
127127 type codexOutputMessage struct {
@@ -240,9 +240,12 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
240240 // checkpoints as well as this stable instruction/tool boundary.
241241 body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}
242242 body.Input = append(body.Input, codexInputMessage{
243- Role: "developer",
244- Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}},
245- PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"},
243+ Role: "developer",
244+ Content: []any{codexInputText{
245+ Type: "input_text",
246+ Text: "[Stable prompt boundary]",
247+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"},
248+ }},
246249 })
247250 }
248251 if m.Reasoning {
@@ -396,6 +399,16 @@ func splitCallID(id string) (string, string) {
396399 return id, ""
397400 }
398401
402+func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics {
403+ diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation}
404+ if wire == nil || wire.PromptCacheOptions == nil {
405+ return diagnostics
406+ }
407+ diagnostics.Eligible = true
408+ diagnostics.Mode = wire.PromptCacheOptions.Mode
409+ return diagnostics
410+}
411+
399412 func usesCodexCLIRouting(model string) bool {
400413 switch model {
401414 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
@@ -412,6 +425,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
412425 if err != nil {
413426 return nil, err
414427 }
428+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full"))
415429 if c.modelName != nil {
416430 wire.Model = c.modelName(wire.Model)
417431 }
@@ -490,10 +504,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
490504 // event vocabulary, so keeping the state machine here prevents the transports
491505 // from drifting in tool-call, reasoning, and usage handling.
492506 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {
493- c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed)
507+ var observe func(string, Message)
508+ if completed != nil {
509+ observe = func(responseID string, _ Message) { completed(responseID) }
510+ }
511+ c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe)
494512 }
495513
496-func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) {
514+func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) {
497515 model, _ := c.findModel(req.Model)
498516 providerName := c.providerName
499517 if providerName == "" {
@@ -729,7 +747,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
729747 stop = StopEnd
730748 }
731749 if completed != nil && p.Response.ID != "" {
732- completed(p.Response.ID)
750+ completed(p.Response.ID, assemble())
733751 }
734752 sendDone()
735753 return
@@ -93,8 +93,9 @@ func (c *codexClient) Name() string { return "openai-codex" }
93 // ---- Responses API wire types (subset needed for zut's surface) ----93 // ---- Responses API wire types (subset needed for zut's surface) ----
94 94
95 type codexInputText struct {95 type codexInputText struct {
96- Type string `json:"type"` // "input_text"96+ Type string `json:"type"` // "input_text"
97- Text string `json:"text"`97+ Text string `json:"text"`
98+ PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
98 }99 }
99 100
100 type codexInputImage struct {101 type codexInputImage struct {
@@ -114,14 +115,13 @@ type codexPromptCacheOptions struct {
114 }115 }
115 116
116 type codexPromptCacheBreakpoint struct {117 type codexPromptCacheBreakpoint struct {
117- Type string `json:"type"`118+ Mode string `json:"mode"`
118 }119 }
119 120
120 type codexInputMessage struct {121 type codexInputMessage struct {
121- Type string `json:"type,omitempty"` // "message" (optional for input)122+ Type string `json:"type,omitempty"` // "message" (optional for input)
122- Role string `json:"role"`123+ Role string `json:"role"`
123- Content []any `json:"content"`124+ Content []any `json:"content"`
124- PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
125 }125 }
126 126
127 type codexOutputMessage struct {127 type codexOutputMessage struct {
@@ -240,9 +240,12 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
240 // checkpoints as well as this stable instruction/tool boundary.240 // checkpoints as well as this stable instruction/tool boundary.
241 body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}241 body.PromptCacheOptions = &codexPromptCacheOptions{Mode: "implicit"}
242 body.Input = append(body.Input, codexInputMessage{242 body.Input = append(body.Input, codexInputMessage{
243- Role: "developer",243+ Role: "developer",
244- Content: []any{codexInputText{Type: "input_text", Text: "[Stable prompt boundary]"}},244+ Content: []any{codexInputText{
245- PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"},245+ Type: "input_text",
246+ Text: "[Stable prompt boundary]",
247+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Mode: "explicit"},
248+ }},
246 })249 })
247 }250 }
248 if m.Reasoning {251 if m.Reasoning {
@@ -396,6 +399,16 @@ func splitCallID(id string) (string, string) {
396 return id, ""399 return id, ""
397 }400 }
398 401
402+func responsesCacheDiagnostics(wire *codexRequest, transport, continuation string) CacheDiagnostics {
403+ diagnostics := CacheDiagnostics{Mode: "none", Transport: transport, Continuation: continuation}
404+ if wire == nil || wire.PromptCacheOptions == nil {
405+ return diagnostics
406+ }
407+ diagnostics.Eligible = true
408+ diagnostics.Mode = wire.PromptCacheOptions.Mode
409+ return diagnostics
410+}
411+
399 func usesCodexCLIRouting(model string) bool {412 func usesCodexCLIRouting(model string) bool {
400 switch model {413 switch model {
401 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":414 case "gpt-5.6-luna", "gpt-5.6-luna-pro", "gpt-5.6-terra", "gpt-5.6-terra-pro":
@@ -412,6 +425,7 @@ func (c *codexClient) Stream(ctx context.Context, req Request) (<-chan Event, er
412 if err != nil {425 if err != nil {
413 return nil, err426 return nil, err
414 }427 }
428+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "http_sse", "full"))
415 if c.modelName != nil {429 if c.modelName != nil {
416 wire.Model = c.modelName(wire.Model)430 wire.Model = c.modelName(wire.Model)
417 }431 }
@@ -490,10 +504,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
490 // event vocabulary, so keeping the state machine here prevents the transports504 // event vocabulary, so keeping the state machine here prevents the transports
491 // from drifting in tool-call, reasoning, and usage handling.505 // from drifting in tool-call, reasoning, and usage handling.
492 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {506 func (c *codexClient) runResponseEvents(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, completed func(string)) {
493- c.runResponseEventsWithFirst(ctx, req, out, raw, nil, completed)507+ var observe func(string, Message)
508+ if completed != nil {
509+ observe = func(responseID string, _ Message) { completed(responseID) }
510+ }
511+ c.runResponseEventsWithFirst(ctx, req, out, raw, nil, observe)
494 }512 }
495 513
496-func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string)) {514+func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Request, out chan<- Event, raw <-chan sseEvent, first *sseEvent, completed func(string, Message)) {
497 model, _ := c.findModel(req.Model)515 model, _ := c.findModel(req.Model)
498 providerName := c.providerName516 providerName := c.providerName
499 if providerName == "" {517 if providerName == "" {
@@ -729,7 +747,7 @@ func (c *codexClient) runResponseEventsWithFirst(ctx context.Context, req Reques
729 stop = StopEnd747 stop = StopEnd
730 }748 }
731 if completed != nil && p.Response.ID != "" {749 if completed != nil && p.Response.ID != "" {
732- completed(p.Response.ID)750+ completed(p.Response.ID, assemble())
733 }751 }
734 sendDone()752 sendDone()
735 return753 return
M packages/provider/openai_codex_test.go +11 -3
@@ -212,9 +212,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
212212 if !ok || boundary["role"] != "developer" {
213213 t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
214214 }
215- breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any)
216- if !ok || breakpoint["type"] != "stable" {
217- t.Fatalf("stable cache breakpoint = %#v", boundary)
215+ content, ok := boundary["content"].([]any)
216+ if !ok || len(content) != 1 {
217+ t.Fatalf("stable cache boundary content = %#v", boundary)
218+ }
219+ text, ok := content[0].(map[string]any)
220+ if !ok || text["type"] != "input_text" {
221+ t.Fatalf("stable cache text block = %#v", content)
222+ }
223+ breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
224+ if !ok || breakpoint["mode"] != "explicit" {
225+ t.Fatalf("stable cache breakpoint = %#v", text)
218226 }
219227 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
220228 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
@@ -212,9 +212,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
212 if !ok || boundary["role"] != "developer" {212 if !ok || boundary["role"] != "developer" {
213 t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])213 t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
214 }214 }
215- breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any)215+ content, ok := boundary["content"].([]any)
216- if !ok || breakpoint["type"] != "stable" {216+ if !ok || len(content) != 1 {
217- t.Fatalf("stable cache breakpoint = %#v", boundary)217+ t.Fatalf("stable cache boundary content = %#v", boundary)
218+ }
219+ text, ok := content[0].(map[string]any)
220+ if !ok || text["type"] != "input_text" {
221+ t.Fatalf("stable cache text block = %#v", content)
222+ }
223+ breakpoint, ok := text["prompt_cache_breakpoint"].(map[string]any)
224+ if !ok || breakpoint["mode"] != "explicit" {
225+ t.Fatalf("stable cache breakpoint = %#v", text)
218 }226 }
219 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {227 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
220 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)228 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
M packages/provider/openai_responses_websocket.go +33 -6
@@ -10,6 +10,7 @@ import (
1010 "reflect"
1111 "strings"
1212 "sync"
13+ "time"
1314
1415 "golang.org/x/net/websocket"
1516 )
@@ -39,6 +40,7 @@ type responsesWebSocketSession struct {
3940 // lastRequest is the complete request accepted by the server. Continuation
4041 // must compare every response-context setting, not merely its transcript.
4142 lastRequest Request
43+ lastOutput Message
4244 lastResponseID string
4345 }
4446
@@ -179,6 +181,16 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
179181
180182 streamReq, previousResponseID := session.incrementalRequest(req)
181183 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
184+ if err == nil {
185+ wire, wireErr := c.http.buildRequest(streamReq)
186+ if wireErr == nil {
187+ continuation := "full"
188+ if previousResponseID != "" {
189+ continuation = "incremental"
190+ }
191+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
192+ }
193+ }
182194 if err != nil {
183195 return nil, err
184196 }
@@ -242,12 +254,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
242254
243255 completed := false
244256 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)
245- c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) {
257+ c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) {
246258 stateMu.Lock()
247259 terminal = true
248260 stateMu.Unlock()
249261 completed = true
250- session.recordCompleted(req, responseID)
262+ session.recordCompleted(req, output, responseID)
251263 })
252264 close(finished)
253265 if !completed {
@@ -427,15 +439,28 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, st
427439 if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
428440 return req, ""
429441 }
430- // The cached response already contains the assistant output from the last
431- // generation. Send only the strict new tool-result and/or user suffix.
442+ // The cached response already contains the exact assistant output from the
443+ // last generation. It must be replayed verbatim in the caller transcript
444+ // before we can safely send only the strict new tool-result/user suffix.
432445 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
433- for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
446+ if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
447+ if !sameResponsesOutput(req.Messages[0], s.lastOutput) {
448+ return req, ""
449+ }
434450 req.Messages = req.Messages[1:]
435451 }
436452 return req, s.lastResponseID
437453 }
438454
455+func sameResponsesOutput(current, previous Message) bool {
456+ // Provider message timestamps are local presentation metadata, not server
457+ // response state. All visible content, call IDs, reasoning replay, and
458+ // metadata remain part of the strict baseline.
459+ current.Time = time.Time{}
460+ previous.Time = time.Time{}
461+ return reflect.DeepEqual(current, previous)
462+}
463+
439464 func sameResponsesContinuationConfig(previous, current Request) bool {
440465 previous.Messages = nil
441466 previous.Context.TurnID = ""
@@ -474,12 +499,13 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
474499 return s.conn, s.raw
475500 }
476501
477-func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) {
502+func (s *responsesWebSocketSession) recordCompleted(request Request, output Message, responseID string) {
478503 s.mu.Lock()
479504 defer s.mu.Unlock()
480505 request.Messages = append([]Message(nil), request.Messages...)
481506 request.Lifecycle = nil
482507 s.lastRequest = request
508+ s.lastOutput = output
483509 s.lastResponseID = responseID
484510 }
485511
@@ -509,6 +535,7 @@ func (s *responsesWebSocketSession) invalidateLocked() {
509535 s.raw = nil
510536 s.readerDone = nil
511537 s.lastRequest = Request{}
538+ s.lastOutput = Message{}
512539 s.lastResponseID = ""
513540 }
514541
@@ -10,6 +10,7 @@ import (
10 "reflect"10 "reflect"
11 "strings"11 "strings"
12 "sync"12 "sync"
13+ "time"
13 14
14 "golang.org/x/net/websocket"15 "golang.org/x/net/websocket"
15 )16 )
@@ -39,6 +40,7 @@ type responsesWebSocketSession struct {
39 // lastRequest is the complete request accepted by the server. Continuation40 // lastRequest is the complete request accepted by the server. Continuation
40 // must compare every response-context setting, not merely its transcript.41 // must compare every response-context setting, not merely its transcript.
41 lastRequest Request42 lastRequest Request
43+ lastOutput Message
42 lastResponseID string44 lastResponseID string
43 }45 }
44 46
@@ -179,6 +181,16 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
179 181
180 streamReq, previousResponseID := session.incrementalRequest(req)182 streamReq, previousResponseID := session.incrementalRequest(req)
181 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)183 payload, err := c.websocketPayload(streamReq, cacheSessionID, previousResponseID)
184+ if err == nil {
185+ wire, wireErr := c.http.buildRequest(streamReq)
186+ if wireErr == nil {
187+ continuation := "full"
188+ if previousResponseID != "" {
189+ continuation = "incremental"
190+ }
191+ ReportCacheDiagnostics(req.Lifecycle, responsesCacheDiagnostics(wire, "websocket", continuation))
192+ }
193+ }
182 if err != nil {194 if err != nil {
183 return nil, err195 return nil, err
184 }196 }
@@ -242,12 +254,12 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
242 254
243 completed := false255 completed := false
244 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)256 turnRaw := filterResponsesWebSocketEvents(raw, responseID, finished)
245- c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string) {257+ c.http.runResponseEventsWithFirst(ctx, req, out, turnRaw, &first, func(responseID string, output Message) {
246 stateMu.Lock()258 stateMu.Lock()
247 terminal = true259 terminal = true
248 stateMu.Unlock()260 stateMu.Unlock()
249 completed = true261 completed = true
250- session.recordCompleted(req, responseID)262+ session.recordCompleted(req, output, responseID)
251 })263 })
252 close(finished)264 close(finished)
253 if !completed {265 if !completed {
@@ -427,15 +439,28 @@ func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, st
427 if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {439 if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
428 return req, ""440 return req, ""
429 }441 }
430- // The cached response already contains the assistant output from the last442+ // The cached response already contains the exact assistant output from the
431- // generation. Send only the strict new tool-result and/or user suffix.443+ // last generation. It must be replayed verbatim in the caller transcript
444+ // before we can safely send only the strict new tool-result/user suffix.
432 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)445 req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
433- for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {446+ if len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
447+ if !sameResponsesOutput(req.Messages[0], s.lastOutput) {
448+ return req, ""
449+ }
434 req.Messages = req.Messages[1:]450 req.Messages = req.Messages[1:]
435 }451 }
436 return req, s.lastResponseID452 return req, s.lastResponseID
437 }453 }
438 454
455+func sameResponsesOutput(current, previous Message) bool {
456+ // Provider message timestamps are local presentation metadata, not server
457+ // response state. All visible content, call IDs, reasoning replay, and
458+ // metadata remain part of the strict baseline.
459+ current.Time = time.Time{}
460+ previous.Time = time.Time{}
461+ return reflect.DeepEqual(current, previous)
462+}
463+
439 func sameResponsesContinuationConfig(previous, current Request) bool {464 func sameResponsesContinuationConfig(previous, current Request) bool {
440 previous.Messages = nil465 previous.Messages = nil
441 previous.Context.TurnID = ""466 previous.Context.TurnID = ""
@@ -474,12 +499,13 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
474 return s.conn, s.raw499 return s.conn, s.raw
475 }500 }
476 501
477-func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) {502+func (s *responsesWebSocketSession) recordCompleted(request Request, output Message, responseID string) {
478 s.mu.Lock()503 s.mu.Lock()
479 defer s.mu.Unlock()504 defer s.mu.Unlock()
480 request.Messages = append([]Message(nil), request.Messages...)505 request.Messages = append([]Message(nil), request.Messages...)
481 request.Lifecycle = nil506 request.Lifecycle = nil
482 s.lastRequest = request507 s.lastRequest = request
508+ s.lastOutput = output
483 s.lastResponseID = responseID509 s.lastResponseID = responseID
484 }510 }
485 511
@@ -509,6 +535,7 @@ func (s *responsesWebSocketSession) invalidateLocked() {
509 s.raw = nil535 s.raw = nil
510 s.readerDone = nil536 s.readerDone = nil
511 s.lastRequest = Request{}537 s.lastRequest = Request{}
538+ s.lastOutput = Message{}
512 s.lastResponseID = ""539 s.lastResponseID = ""
513 }540 }
514 541
M packages/provider/provider.go +21 -0
@@ -301,6 +301,27 @@ type RequestAttemptIDLifecycle interface {
301301 RequestAttemptID(requestID string, attempt, maxAttempts int)
302302 }
303303
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+
304325 // RequestFailureReason is an allowlisted request-failure category. It is safe
305326 // to persist; raw provider errors and response bodies are not.
306327 type RequestFailureReason string
@@ -301,6 +301,27 @@ type RequestAttemptIDLifecycle interface {
301 RequestAttemptID(requestID string, attempt, maxAttempts int)301 RequestAttemptID(requestID string, attempt, maxAttempts int)
302 }302 }
303 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+
304 // RequestFailureReason is an allowlisted request-failure category. It is safe325 // RequestFailureReason is an allowlisted request-failure category. It is safe
305 // to persist; raw provider errors and response bodies are not.326 // to persist; raw provider errors and response bodies are not.
306 type RequestFailureReason string327 type RequestFailureReason string

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