detached 808bb65

feat(core): stabilize dynamic prompt context

commit
808bb6548eea910d912ac3d1396ad840a8cba95a
author
brice · · 2026-08-18 08:34 UTC
parent
5d2b756
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
17 files · +270 -78
M docs/providers.md +22 -11
@@ -150,22 +150,33 @@ 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.
162+
163+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 confirmed
165+cache miss; no percentage means the provider or historical session did not
166+report cache detail. Cache writes remain separate from that hit rate. Provider
167+retention and eviction, request routing, and OpenAI's 1,024-token eligibility
168+minimum can still produce a miss even when the stable prefix is unchanged.
160169
161170 ### OpenAI Responses WebSocket mode
162171
163172 For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses
164173 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
174+connection per conversation thread, sends one `response.create` at a time,
175+and continues the most recent completed response with `previous_response_id`
176+only when the complete response-context configuration and prior input prefix
177+match exactly. Compatible continuations send only new tool or user input.
178+The request uses `store:false`; its only provider-specific handshake header is
179+`Authorization: Bearer ...`, never the
169180 ChatGPT/Codex account, routing, or session headers.
170181
171182 The capability is deliberately not inherited by `--base-url`, compatible
@@ -150,22 +150,33 @@ 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.
162+
163+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 confirmed
165+cache miss; no percentage means the provider or historical session did not
166+report cache detail. Cache writes remain separate from that hit rate. Provider
167+retention and eviction, request routing, and OpenAI's 1,024-token eligibility
168+minimum can still produce a miss even when the stable prefix is unchanged.
160 169
161 ### OpenAI Responses WebSocket mode170 ### OpenAI Responses WebSocket mode
162 171
163 For the exact public endpoint `https://api.openai.com/v1/responses`, zut uses172 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 one173 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, and174+connection per conversation thread, sends one `response.create` at a time,
166-continues the most recent completed response with `previous_response_id` plus175+and continues the most recent completed response with `previous_response_id`
167-only new tool or user input. The request uses `store:false`; its only176+only when the complete response-context configuration and prior input prefix
168-provider-specific handshake header is `Authorization: Bearer ...`, never the177+match exactly. Compatible continuations send only new tool or user input.
178+The request uses `store:false`; its only provider-specific handshake header is
179+`Authorization: Bearer ...`, never the
169 ChatGPT/Codex account, routing, or session headers.180 ChatGPT/Codex account, routing, or session headers.
170 181
171 The capability is deliberately not inherited by `--base-url`, compatible182 The capability is deliberately not inherited by `--base-url`, compatible
M packages/core/agent.go +7 -12
@@ -164,8 +164,9 @@ 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
167+ queued []queuedMessage
168+ timeContext agentTimeContext
169+ hasSessionTimeContext bool
169170 // sessionID identifies this conversation thread. cacheSessionID identifies
170171 // the root prompt-cache affinity and may be shared by resident child
171172 // threads. Hosts bind both before the first provider request; unbound
@@ -623,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
623624 }
624625 }
625626
627+ a.appendDynamicContext(turnContext)
628+
626629 var (
627630 stop provider.StopReason
628631 assistantMsg provider.Message
@@ -988,13 +991,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
988991 return provider.StopError, provider.Message{}, err
989992 }
990993 system, tools := a.PromptConfig()
991- systemContext := a.providerTimeContext().systemText()
992- if contextText := boundedTurnContext(turnContext); contextText != "" {
993- if system != "" {
994- system += "\n\n"
995- }
996- system += "[Extension context for this turn]\n" + contextText
997- }
998994 // Repair pairs before projecting the copied provider-input view. The
999995 // repair can add stub results for aborted calls; those results must also
1000996 // remain in the request so every tool call still has a matching result.
@@ -1006,9 +1002,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
10061002 model: a.Model,
10071003 }
10081004 req := provider.Request{
1009- Model: a.Model,
1010- System: system,
1011- SystemContext: systemContext,
1005+ Model: a.Model,
1006+ System: system,
10121007 // Repair any dangling tool_use blocks before sending. A turn
10131008 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
10141009 // dev server, etc.) can leave an assistant tool_use with no
@@ -164,8 +164,9 @@ 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+ hasSessionTimeContext bool
169 // sessionID identifies this conversation thread. cacheSessionID identifies170 // sessionID identifies this conversation thread. cacheSessionID identifies
170 // the root prompt-cache affinity and may be shared by resident child171 // the root prompt-cache affinity and may be shared by resident child
171 // threads. Hosts bind both before the first provider request; unbound172 // threads. Hosts bind both before the first provider request; unbound
@@ -623,6 +624,8 @@ func (a *Agent) runLoop(ctx context.Context, sink func(AgentEvent), requestConte
623 }624 }
624 }625 }
625 626
627+ a.appendDynamicContext(turnContext)
628+
626 var (629 var (
627 stop provider.StopReason630 stop provider.StopReason
628 assistantMsg provider.Message631 assistantMsg provider.Message
@@ -988,13 +991,6 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
988 return provider.StopError, provider.Message{}, err991 return provider.StopError, provider.Message{}, err
989 }992 }
990 system, tools := a.PromptConfig()993 system, tools := a.PromptConfig()
991- systemContext := a.providerTimeContext().systemText()
992- if contextText := boundedTurnContext(turnContext); contextText != "" {
993- if system != "" {
994- system += "\n\n"
995- }
996- system += "[Extension context for this turn]\n" + contextText
997- }
998 // Repair pairs before projecting the copied provider-input view. The994 // Repair pairs before projecting the copied provider-input view. The
999 // repair can add stub results for aborted calls; those results must also995 // repair can add stub results for aborted calls; those results must also
1000 // remain in the request so every tool call still has a matching result.996 // remain in the request so every tool call still has a matching result.
@@ -1006,9 +1002,8 @@ func (a *Agent) oneTurn(ctx context.Context, sink func(AgentEvent), turnContext
1006 model: a.Model,1002 model: a.Model,
1007 }1003 }
1008 req := provider.Request{1004 req := provider.Request{
1009- Model: a.Model,1005+ Model: a.Model,
1010- System: system,1006+ System: system,
1011- SystemContext: systemContext,
1012 // Repair any dangling tool_use blocks before sending. A turn1007 // Repair any dangling tool_use blocks before sending. A turn
1013 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a1008 // aborted mid-flight (cancel, connection drop, ECONNREFUSED to a
1014 // dev server, etc.) can leave an assistant tool_use with no1009 // dev server, etc.) can leave an assistant tool_use with no
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 +10 -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,12 @@ 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+ Model: a.Model,
68+ System: summarizationSystem,
69+ MaxTokens: 4096,
70+ Temperature: a.Temperature,
71+ FastMode: fastMode,
7372 Messages: []provider.Message{
7473 {
7574 Role: provider.RoleUser,
@@ -171,7 +170,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
171170 // a tool_use ID that doesn't exist.
172171 tail = repairOrphanedToolResults(tail)
173172
174- next := make([]provider.Message, 0, 1+len(tail))
173+ next := make([]provider.Message, 0, 2+len(tail))
174+ if latestContext != nil {
175+ next = append(next, *latestContext)
176+ }
175177 next = append(next, synthetic)
176178 next = append(next, tail...)
177179
@@ -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,12 @@ 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+ Model: a.Model,
68- System: summarizationSystem,68+ System: summarizationSystem,
69- SystemContext: systemContext,69+ MaxTokens: 4096,
70- MaxTokens: 4096,70+ Temperature: a.Temperature,
71- Temperature: a.Temperature,71+ FastMode: fastMode,
72- FastMode: fastMode,
73 Messages: []provider.Message{72 Messages: []provider.Message{
74 {73 {
75 Role: provider.RoleUser,74 Role: provider.RoleUser,
@@ -171,7 +170,10 @@ func (a *Agent) compact(ctx context.Context, keepTail int, textSink func(delta s
171 // a tool_use ID that doesn't exist.170 // a tool_use ID that doesn't exist.
172 tail = repairOrphanedToolResults(tail)171 tail = repairOrphanedToolResults(tail)
173 172
174- next := make([]provider.Message, 0, 1+len(tail))173+ next := make([]provider.Message, 0, 2+len(tail))
174+ if latestContext != nil {
175+ next = append(next, *latestContext)
176+ }
175 next = append(next, synthetic)177 next = append(next, synthetic)
176 next = append(next, tail...)178 next = append(next, tail...)
177 179
M packages/core/compact_lifecycle_test.go +18 -0
@@ -53,6 +53,24 @@ 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+
5674 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
5775 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
5876 agent := NewAgent(client, "compact-model", "system", Registry{})
@@ -53,6 +53,24 @@ 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+
56 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {74 func TestCompactWithEventsAccumulatesAndPersistsUsage(t *testing.T) {
57 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}75 client := &compactLifecycleClient{usage: []provider.Usage{{InputTokens: 2}, {OutputTokens: 3, CostUSD: 0.25}}}
58 agent := NewAgent(client, "compact-model", "system", Registry{})76 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 }
A packages/core/dynamic_context.go +94 -0
@@ -0,0 +1,94 @@
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. Later
48+ // snapshots extend the history at the next model boundary.
49+ insertAt := len(a.messages)
50+ if !hasInternalContextMessage(a.messages) {
51+ insertAt = 0
52+ }
53+ a.messages = append(a.messages, provider.Message{})
54+ copy(a.messages[insertAt+1:], a.messages[insertAt:])
55+ a.messages[insertAt] = message
56+ a.rev++
57+ a.mu.Unlock()
58+ a.fireMessageAppended(message)
59+}
60+
61+func hasInternalContextMessage(messages []provider.Message) bool {
62+ for _, message := range messages {
63+ if isInternalContextMessage(message) {
64+ return true
65+ }
66+ }
67+ return false
68+}
69+
70+func isInternalContextMessage(message provider.Message) bool {
71+ return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true"
72+}
73+
74+func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) {
75+ filtered := make([]provider.Message, 0, len(messages))
76+ var latest *provider.Message
77+ for _, message := range messages {
78+ if isInternalContextMessage(message) {
79+ copy := message
80+ latest = &copy
81+ continue
82+ }
83+ filtered = append(filtered, message)
84+ }
85+ return latest, filtered
86+}
87+
88+func internalContextText(message provider.Message) string {
89+ if len(message.Content) != 1 {
90+ return ""
91+ }
92+ text, _ := message.Content[0].(provider.TextBlock)
93+ return text.Text
94+}
@@ -0,0 +1,94 @@
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. Later
48+ // snapshots extend the history at the next model boundary.
49+ insertAt := len(a.messages)
50+ if !hasInternalContextMessage(a.messages) {
51+ insertAt = 0
52+ }
53+ a.messages = append(a.messages, provider.Message{})
54+ copy(a.messages[insertAt+1:], a.messages[insertAt:])
55+ a.messages[insertAt] = message
56+ a.rev++
57+ a.mu.Unlock()
58+ a.fireMessageAppended(message)
59+}
60+
61+func hasInternalContextMessage(messages []provider.Message) bool {
62+ for _, message := range messages {
63+ if isInternalContextMessage(message) {
64+ return true
65+ }
66+ }
67+ return false
68+}
69+
70+func isInternalContextMessage(message provider.Message) bool {
71+ return message.Role == provider.RoleDeveloper && message.Meta[internalContextMarker] == "true"
72+}
73+
74+func latestInternalContext(messages []provider.Message) (*provider.Message, []provider.Message) {
75+ filtered := make([]provider.Message, 0, len(messages))
76+ var latest *provider.Message
77+ for _, message := range messages {
78+ if isInternalContextMessage(message) {
79+ copy := message
80+ latest = &copy
81+ continue
82+ }
83+ filtered = append(filtered, message)
84+ }
85+ return latest, filtered
86+}
87+
88+func internalContextText(message provider.Message) string {
89+ if len(message.Content) != 1 {
90+ return ""
91+ }
92+ text, _ := message.Content[0].(provider.TextBlock)
93+ return text.Text
94+}
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/anthropic.go +1 -1
@@ -357,7 +357,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
357357 for _, msg := range req.Messages {
358358 renameTools := c.oauthTok != ""
359359 switch msg.Role {
360- case RoleUser:
360+ case RoleDeveloper, RoleUser:
361361 out.Messages = append(out.Messages, anthMessage{
362362 Role: "user",
363363 Content: convertAnthContent(msg.Content, renameTools),
@@ -357,7 +357,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
357 for _, msg := range req.Messages {357 for _, msg := range req.Messages {
358 renameTools := c.oauthTok != ""358 renameTools := c.oauthTok != ""
359 switch msg.Role {359 switch msg.Role {
360- case RoleUser:360+ case RoleDeveloper, 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),
M packages/provider/gemini.go +1 -1
@@ -196,7 +196,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
196196 }
197197 for _, msg := range msgs {
198198 switch msg.Role {
199- case RoleUser:
199+ case RoleDeveloper, RoleUser:
200200 parts := convertGemUserParts(msg.Content)
201201 if len(parts) == 0 {
202202 continue
@@ -196,7 +196,7 @@ func (c *geminiClient) buildRequest(req Request) (*gemRequest, string, error) {
196 }196 }
197 for _, msg := range msgs {197 for _, msg := range msgs {
198 switch msg.Role {198 switch msg.Role {
199- case RoleUser:199+ case RoleDeveloper, RoleUser:
200 parts := convertGemUserParts(msg.Content)200 parts := convertGemUserParts(msg.Content)
201 if len(parts) == 0 {201 if len(parts) == 0 {
202 continue202 continue
M packages/provider/openai.go +3 -0
@@ -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})
@@ -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})
M packages/provider/openai_codex.go +51 -22
@@ -51,10 +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
56- ThreadHeader bool
57- ClientRequestHeader bool
54+ StablePromptCacheKey bool
55+ ExplicitPromptCacheBreakpoint bool
56+ SessionHeader bool
57+ ThreadHeader bool
58+ ClientRequestHeader bool
5859 }
5960
6061 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -108,10 +109,19 @@ type codexOutputText struct {
108109 Annotations []any `json:"annotations"`
109110 }
110111
112+type codexPromptCacheOptions struct {
113+ Mode string `json:"mode"`
114+}
115+
116+type codexPromptCacheBreakpoint struct {
117+ Type string `json:"type"`
118+}
119+
111120 type codexInputMessage struct {
112- Type string `json:"type,omitempty"` // "message" (optional for input)
113- Role string `json:"role"`
114- Content []any `json:"content"`
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"`
115125 }
116126
117127 type codexOutputMessage struct {
@@ -166,19 +176,20 @@ type codexReasoningConfig struct {
166176 }
167177
168178 type codexRequest struct {
169- Model string `json:"model"`
170- Store bool `json:"store"`
171- Stream bool `json:"stream,omitempty"`
172- Instructions string `json:"instructions,omitempty"`
173- Input []any `json:"input"`
174- Tools []codexTool `json:"tools,omitempty"`
175- ToolChoice string `json:"tool_choice,omitempty"`
176- ParallelToolCalls bool `json:"parallel_tool_calls"`
177- Include []string `json:"include,omitempty"`
178- Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
179- PromptCacheKey string `json:"prompt_cache_key,omitempty"`
180- ServiceTier string `json:"service_tier,omitempty"`
181- 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"`
182193 }
183194
184195 // ---- Request building ----
@@ -195,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) {
195206 return FindModel("openai", id)
196207 }
197208
209+func isGPT56Model(model string) bool {
210+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-")
211+}
212+
198213 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
199214 providerName := c.providerName
200215 if providerName == "" {
@@ -220,6 +235,16 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
220235 if req.FastMode {
221236 body.ServiceTier = fastModeServiceTier
222237 }
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{Type: "input_text", Text: "[Stable prompt boundary]"}},
245+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"},
246+ })
247+ }
223248 if m.Reasoning {
224249 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
225250 if hasReasoningLevelOverride(m, req.Reasoning) {
@@ -250,7 +275,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
250275 req.Messages = RepairOrphanedToolResults(req.Messages)
251276 for _, msg := range req.Messages {
252277 switch msg.Role {
253- case RoleUser:
278+ case RoleDeveloper, RoleUser:
254279 content := []any{}
255280 for _, c := range msg.Content {
256281 switch v := c.(type) {
@@ -266,7 +291,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
266291 if len(content) == 0 {
267292 continue
268293 }
269- body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
294+ role := "user"
295+ if msg.Role == RoleDeveloper {
296+ role = "developer"
297+ }
298+ body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
270299 case RoleAssistant:
271300 // Emit one output_message per text block, one function_call per
272301 // tool call, and one reasoning item per ReasoningBlock,
@@ -51,10 +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- ThreadHeader bool56+ SessionHeader bool
57- ClientRequestHeader bool57+ ThreadHeader bool
58+ ClientRequestHeader bool
58 }59 }
59 60
60 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint61 // NewOpenAICodex creates a client that talks to ChatGPT's Codex endpoint
@@ -108,10 +109,19 @@ type codexOutputText struct {
108 Annotations []any `json:"annotations"`109 Annotations []any `json:"annotations"`
109 }110 }
110 111
112+type codexPromptCacheOptions struct {
113+ Mode string `json:"mode"`
114+}
115+
116+type codexPromptCacheBreakpoint struct {
117+ Type string `json:"type"`
118+}
119+
111 type codexInputMessage struct {120 type codexInputMessage struct {
112- Type string `json:"type,omitempty"` // "message" (optional for input)121+ Type string `json:"type,omitempty"` // "message" (optional for input)
113- Role string `json:"role"`122+ Role string `json:"role"`
114- Content []any `json:"content"`123+ Content []any `json:"content"`
124+ PromptCacheBreakpoint *codexPromptCacheBreakpoint `json:"prompt_cache_breakpoint,omitempty"`
115 }125 }
116 126
117 type codexOutputMessage struct {127 type codexOutputMessage struct {
@@ -166,19 +176,20 @@ type codexReasoningConfig struct {
166 }176 }
167 177
168 type codexRequest struct {178 type codexRequest struct {
169- Model string `json:"model"`179+ Model string `json:"model"`
170- Store bool `json:"store"`180+ Store bool `json:"store"`
171- Stream bool `json:"stream,omitempty"`181+ Stream bool `json:"stream,omitempty"`
172- Instructions string `json:"instructions,omitempty"`182+ Instructions string `json:"instructions,omitempty"`
173- Input []any `json:"input"`183+ Input []any `json:"input"`
174- Tools []codexTool `json:"tools,omitempty"`184+ Tools []codexTool `json:"tools,omitempty"`
175- ToolChoice string `json:"tool_choice,omitempty"`185+ ToolChoice string `json:"tool_choice,omitempty"`
176- ParallelToolCalls bool `json:"parallel_tool_calls"`186+ ParallelToolCalls bool `json:"parallel_tool_calls"`
177- Include []string `json:"include,omitempty"`187+ Include []string `json:"include,omitempty"`
178- Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`188+ Reasoning *codexReasoningConfig `json:"reasoning,omitempty"`
179- PromptCacheKey string `json:"prompt_cache_key,omitempty"`189+ PromptCacheKey string `json:"prompt_cache_key,omitempty"`
180- ServiceTier string `json:"service_tier,omitempty"`190+ PromptCacheOptions *codexPromptCacheOptions `json:"prompt_cache_options,omitempty"`
181- PreviousResponseID string `json:"previous_response_id,omitempty"`191+ ServiceTier string `json:"service_tier,omitempty"`
192+ PreviousResponseID string `json:"previous_response_id,omitempty"`
182 }193 }
183 194
184 // ---- Request building ----195 // ---- Request building ----
@@ -195,6 +206,10 @@ func (c *codexClient) findModel(id string) (Model, error) {
195 return FindModel("openai", id)206 return FindModel("openai", id)
196 }207 }
197 208
209+func isGPT56Model(model string) bool {
210+ return strings.HasPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-5.6-")
211+}
212+
198 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {213 func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
199 providerName := c.providerName214 providerName := c.providerName
200 if providerName == "" {215 if providerName == "" {
@@ -220,6 +235,16 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
220 if req.FastMode {235 if req.FastMode {
221 body.ServiceTier = fastModeServiceTier236 body.ServiceTier = fastModeServiceTier
222 }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{Type: "input_text", Text: "[Stable prompt boundary]"}},
245+ PromptCacheBreakpoint: &codexPromptCacheBreakpoint{Type: "stable"},
246+ })
247+ }
223 if m.Reasoning {248 if m.Reasoning {
224 effort := OpenAICodexReasoningEffort(reasoning, req.Model)249 effort := OpenAICodexReasoningEffort(reasoning, req.Model)
225 if hasReasoningLevelOverride(m, req.Reasoning) {250 if hasReasoningLevelOverride(m, req.Reasoning) {
@@ -250,7 +275,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
250 req.Messages = RepairOrphanedToolResults(req.Messages)275 req.Messages = RepairOrphanedToolResults(req.Messages)
251 for _, msg := range req.Messages {276 for _, msg := range req.Messages {
252 switch msg.Role {277 switch msg.Role {
253- case RoleUser:278+ case RoleDeveloper, RoleUser:
254 content := []any{}279 content := []any{}
255 for _, c := range msg.Content {280 for _, c := range msg.Content {
256 switch v := c.(type) {281 switch v := c.(type) {
@@ -266,7 +291,11 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
266 if len(content) == 0 {291 if len(content) == 0 {
267 continue292 continue
268 }293 }
269- body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})294+ role := "user"
295+ if msg.Role == RoleDeveloper {
296+ role = "developer"
297+ }
298+ body.Input = append(body.Input, codexInputMessage{Role: role, Content: content})
270 case RoleAssistant:299 case RoleAssistant:
271 // Emit one output_message per text block, one function_call per300 // Emit one output_message per text block, one function_call per
272 // tool call, and one reasoning item per ReasoningBlock,301 // tool call, and one reasoning item per ReasoningBlock,
M packages/provider/openai_codex_test.go +11 -0
@@ -205,6 +205,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
205205 if gotBody.PromptCacheKey != "cache-root-2" {
206206 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
207207 }
208+ if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
209+ t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
210+ }
211+ boundary, ok := gotBody.Input[0].(map[string]any)
212+ if !ok || boundary["role"] != "developer" {
213+ t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
214+ }
215+ breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any)
216+ if !ok || breakpoint["type"] != "stable" {
217+ t.Fatalf("stable cache breakpoint = %#v", boundary)
218+ }
208219 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {
209220 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
210221 }
@@ -205,6 +205,17 @@ func TestOpenAIGPT56DoesNotUseCodexCLIRouting(t *testing.T) {
205 if gotBody.PromptCacheKey != "cache-root-2" {205 if gotBody.PromptCacheKey != "cache-root-2" {
206 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)206 t.Fatalf("prompt_cache_key = %q, want root cache ID", gotBody.PromptCacheKey)
207 }207 }
208+ if gotBody.PromptCacheOptions == nil || gotBody.PromptCacheOptions.Mode != "implicit" || len(gotBody.Input) < 2 {
209+ t.Fatalf("explicit cache boundary = options=%+v input=%#v", gotBody.PromptCacheOptions, gotBody.Input)
210+ }
211+ boundary, ok := gotBody.Input[0].(map[string]any)
212+ if !ok || boundary["role"] != "developer" {
213+ t.Fatalf("stable cache boundary = %#v", gotBody.Input[0])
214+ }
215+ breakpoint, ok := boundary["prompt_cache_breakpoint"].(map[string]any)
216+ if !ok || breakpoint["type"] != "stable" {
217+ t.Fatalf("stable cache breakpoint = %#v", boundary)
218+ }
208 if gotReq.Header.Get("originator") != "" || gotReq.Header.Get("user-agent") != "" || gotReq.Header.Get("chatgpt-account-id") != "" || gotReq.Header.Get("openai-beta") != "" {219 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)220 t.Fatalf("public fallback leaked Codex headers: %v", gotReq.Header)
210 }221 }
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 +23 -8
@@ -36,7 +36,9 @@ type responsesWebSocketSession struct {
3636 raw chan sseEvent
3737 readerDone chan struct{}
3838
39- lastRequest []Message
39+ // lastRequest is the complete request accepted by the server. Continuation
40+ // must compare every response-context setting, not merely its transcript.
41+ lastRequest Request
4042 lastResponseID string
4143 }
4244
@@ -245,7 +247,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
245247 terminal = true
246248 stateMu.Unlock()
247249 completed = true
248- session.recordCompleted(req.Messages, responseID)
250+ session.recordCompleted(req, responseID)
249251 })
250252 close(finished)
251253 if !completed {
@@ -421,18 +423,29 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req
421423 func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) {
422424 s.mu.Lock()
423425 defer s.mu.Unlock()
424- if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) {
426+ previous := s.lastRequest
427+ if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
425428 return req, ""
426429 }
427430 // The cached response already contains the assistant output from the last
428- // generation. Send only the new tool results and/or user message.
429- req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...)
431+ // generation. Send only the strict new tool-result and/or user suffix.
432+ req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
430433 for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
431434 req.Messages = req.Messages[1:]
432435 }
433436 return req, s.lastResponseID
434437 }
435438
439+func sameResponsesContinuationConfig(previous, current Request) bool {
440+ previous.Messages = nil
441+ previous.Context.TurnID = ""
442+ previous.Lifecycle = nil
443+ current.Messages = nil
444+ current.Context.TurnID = ""
445+ current.Lifecycle = nil
446+ return reflect.DeepEqual(previous, current)
447+}
448+
436449 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
437450 s.mu.Lock()
438451 if s.conn != nil && s.raw != nil {
@@ -461,10 +474,12 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
461474 return s.conn, s.raw
462475 }
463476
464-func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) {
477+func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) {
465478 s.mu.Lock()
466479 defer s.mu.Unlock()
467- s.lastRequest = append(s.lastRequest[:0], messages...)
480+ request.Messages = append([]Message(nil), request.Messages...)
481+ request.Lifecycle = nil
482+ s.lastRequest = request
468483 s.lastResponseID = responseID
469484 }
470485
@@ -493,7 +508,7 @@ func (s *responsesWebSocketSession) invalidateLocked() {
493508 s.conn = nil
494509 s.raw = nil
495510 s.readerDone = nil
496- s.lastRequest = nil
511+ s.lastRequest = Request{}
497512 s.lastResponseID = ""
498513 }
499514
@@ -36,7 +36,9 @@ type responsesWebSocketSession struct {
36 raw chan sseEvent36 raw chan sseEvent
37 readerDone chan struct{}37 readerDone chan struct{}
38 38
39- lastRequest []Message39+ // lastRequest is the complete request accepted by the server. Continuation
40+ // must compare every response-context setting, not merely its transcript.
41+ lastRequest Request
40 lastResponseID string42 lastResponseID string
41 }43 }
42 44
@@ -245,7 +247,7 @@ func (c *responsesWebSocketClient) Stream(ctx context.Context, req Request) (<-c
245 terminal = true247 terminal = true
246 stateMu.Unlock()248 stateMu.Unlock()
247 completed = true249 completed = true
248- session.recordCompleted(req.Messages, responseID)250+ session.recordCompleted(req, responseID)
249 })251 })
250 close(finished)252 close(finished)
251 if !completed {253 if !completed {
@@ -421,18 +423,29 @@ func (c *responsesWebSocketClient) forwardResponsesHTTP(ctx context.Context, req
421 func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) {423 func (s *responsesWebSocketSession) incrementalRequest(req Request) (Request, string) {
422 s.mu.Lock()424 s.mu.Lock()
423 defer s.mu.Unlock()425 defer s.mu.Unlock()
424- if s.lastResponseID == "" || len(req.Messages) < len(s.lastRequest) || !reflect.DeepEqual(req.Messages[:len(s.lastRequest)], s.lastRequest) {426+ previous := s.lastRequest
427+ if s.lastResponseID == "" || !sameResponsesContinuationConfig(previous, req) || len(req.Messages) < len(previous.Messages) || !reflect.DeepEqual(req.Messages[:len(previous.Messages)], previous.Messages) {
425 return req, ""428 return req, ""
426 }429 }
427 // The cached response already contains the assistant output from the last430 // The cached response already contains the assistant output from the last
428- // generation. Send only the new tool results and/or user message.431+ // generation. Send only the strict new tool-result and/or user suffix.
429- req.Messages = append([]Message(nil), req.Messages[len(s.lastRequest):]...)432+ req.Messages = append([]Message(nil), req.Messages[len(previous.Messages):]...)
430 for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {433 for len(req.Messages) > 0 && req.Messages[0].Role == RoleAssistant {
431 req.Messages = req.Messages[1:]434 req.Messages = req.Messages[1:]
432 }435 }
433 return req, s.lastResponseID436 return req, s.lastResponseID
434 }437 }
435 438
439+func sameResponsesContinuationConfig(previous, current Request) bool {
440+ previous.Messages = nil
441+ previous.Context.TurnID = ""
442+ previous.Lifecycle = nil
443+ current.Messages = nil
444+ current.Context.TurnID = ""
445+ current.Lifecycle = nil
446+ return reflect.DeepEqual(previous, current)
447+}
448+
436 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {449 func (s *responsesWebSocketSession) ensureConnected(ctx context.Context, dial func(context.Context) (*websocket.Conn, error)) error {
437 s.mu.Lock()450 s.mu.Lock()
438 if s.conn != nil && s.raw != nil {451 if s.conn != nil && s.raw != nil {
@@ -461,10 +474,12 @@ func (s *responsesWebSocketSession) connection() (*websocket.Conn, <-chan sseEve
461 return s.conn, s.raw474 return s.conn, s.raw
462 }475 }
463 476
464-func (s *responsesWebSocketSession) recordCompleted(messages []Message, responseID string) {477+func (s *responsesWebSocketSession) recordCompleted(request Request, responseID string) {
465 s.mu.Lock()478 s.mu.Lock()
466 defer s.mu.Unlock()479 defer s.mu.Unlock()
467- s.lastRequest = append(s.lastRequest[:0], messages...)480+ request.Messages = append([]Message(nil), request.Messages...)
481+ request.Lifecycle = nil
482+ s.lastRequest = request
468 s.lastResponseID = responseID483 s.lastResponseID = responseID
469 }484 }
470 485
@@ -493,7 +508,7 @@ func (s *responsesWebSocketSession) invalidateLocked() {
493 s.conn = nil508 s.conn = nil
494 s.raw = nil509 s.raw = nil
495 s.readerDone = nil510 s.readerDone = nil
496- s.lastRequest = nil511+ s.lastRequest = Request{}
497 s.lastResponseID = ""512 s.lastResponseID = ""
498 }513 }
499 514
M packages/provider/openai_responses_websocket_test.go +3 -2
@@ -169,8 +169,9 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
169169 }
170170 assertCompletedResponse(t, client, first)
171171 second := Request{
172- Model: "gpt-5.6-sol",
173- Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
172+ Model: "gpt-5.6-sol",
173+ FastMode: true,
174+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174175 Messages: append(append([]Message(nil), first.Messages...),
175176 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176177 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
@@ -169,8 +169,9 @@ func TestResponsesWebSocketReusesSessionAndSendsIncrementalInput(t *testing.T) {
169 }169 }
170 assertCompletedResponse(t, client, first)170 assertCompletedResponse(t, client, first)
171 second := Request{171 second := Request{
172- Model: "gpt-5.6-sol",172+ Model: "gpt-5.6-sol",
173- Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},173+ FastMode: true,
174+ Context: RequestContext{CacheSessionID: "cache-root-1", ThreadID: "thread-1", TurnID: "turn-2"},
174 Messages: append(append([]Message(nil), first.Messages...),175 Messages: append(append([]Message(nil), first.Messages...),
175 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},176 Message{Role: RoleAssistant, Content: []Content{TextBlock{Text: "ok"}}},
176 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},177 Message{Role: RoleUser, Content: []Content{TextBlock{Text: "second"}}},
M packages/provider/provider.go +3 -0
@@ -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,
@@ -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,

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