detached 7134fb7

provider: replay reasoning items and repair orphan tool results

commit
7134fb7c2ac30fcd53f3d7c7013ff15b7deb7d40
author
patriceckhart · · 2026-05-05 13:00 UTC
parent
ae4e019
signature
unsigned
refs
tag: v0.1.54
9 files · +226 -63
M internal/agent/cli.go +3 -3
@@ -113,7 +113,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
113113 // dropped to keep the per-extension stream sane.
114114 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
115115 if keepTail <= 0 || len(msgs) <= keepTail {
116- return msgs
116+ return provider.RepairOrphanedToolResults(msgs)
117117 }
118118 var out []provider.Message
119119 start := len(msgs) - keepTail
@@ -128,7 +128,7 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
128128 start++
129129 }
130130 out = append(out, msgs[start:]...)
131- return out
131+ return provider.RepairOrphanedToolResults(out)
132132 }
133133
134134 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -586,7 +586,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
586586 return err
587587 }
588588 fullMsgCount := len(msgs)
589- msgs = trimMessagesForResume(msgs, 20)
589+ msgs = trimMessagesForResume(msgs, 100)
590590 persistMu.Lock()
591591 // Flush any unsaved messages to the old session before swapping.
592592 // Per-message persistence keeps sessBaselineMsgs current, so
@@ -113,7 +113,7 @@ func (a *extToolAdapter) NewExtensionTool(info ExtensionToolInfo) core.Tool {
113 // dropped to keep the per-extension stream sane.113 // dropped to keep the per-extension stream sane.
114 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {114 func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message {
115 if keepTail <= 0 || len(msgs) <= keepTail {115 if keepTail <= 0 || len(msgs) <= keepTail {
116- return msgs116+ return provider.RepairOrphanedToolResults(msgs)
117 }117 }
118 var out []provider.Message118 var out []provider.Message
119 start := len(msgs) - keepTail119 start := len(msgs) - keepTail
@@ -128,7 +128,7 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes
128 start++128 start++
129 }129 }
130 out = append(out, msgs[start:]...)130 out = append(out, msgs[start:]...)
131- return out131+ return provider.RepairOrphanedToolResults(out)
132 }132 }
133 133
134 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {134 func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) {
@@ -586,7 +586,7 @@ func runInteractive(ctx context.Context, args Args, version string) error {
586 return err586 return err
587 }587 }
588 fullMsgCount := len(msgs)588 fullMsgCount := len(msgs)
589- msgs = trimMessagesForResume(msgs, 20)589+ msgs = trimMessagesForResume(msgs, 100)
590 persistMu.Lock()590 persistMu.Lock()
591 // Flush any unsaved messages to the old session before swapping.591 // Flush any unsaved messages to the old session before swapping.
592 // Per-message persistence keeps sessBaselineMsgs current, so592 // Per-message persistence keeps sessBaselineMsgs current, so
M internal/core/compact.go +1 -30
@@ -124,36 +124,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
124124 // compaction when the tail preserves a tool_result but the tool_use
125125 // that produced it was summarized away.
126126 func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
127- // Collect all tool_use IDs present in the messages.
128- useIDs := map[string]bool{}
129- for _, m := range msgs {
130- for _, c := range m.Content {
131- if tc, ok := c.(provider.ToolCallBlock); ok {
132- useIDs[tc.ID] = true
133- }
134- }
135- }
136-
137- // Filter out tool_result blocks referencing missing tool_use IDs.
138- out := make([]provider.Message, 0, len(msgs))
139- for _, m := range msgs {
140- var filtered []provider.Content
141- for _, c := range m.Content {
142- if tr, ok := c.(provider.ToolResultBlock); ok {
143- if !useIDs[tr.CallID] {
144- continue // orphaned
145- }
146- }
147- filtered = append(filtered, c)
148- }
149- if len(filtered) > 0 {
150- copy := m
151- copy.Content = filtered
152- out = append(out, copy)
153- }
154- // Drop messages that became empty after filtering.
155- }
156- return out
127+ return provider.RepairOrphanedToolResults(msgs)
157128 }
158129
159130 // serializeTranscript renders a list of provider.Message into a plain
@@ -124,36 +124,7 @@ func (a *Agent) Compact(ctx context.Context, keepTail int, sink func(delta strin
124 // compaction when the tail preserves a tool_result but the tool_use124 // compaction when the tail preserves a tool_result but the tool_use
125 // that produced it was summarized away.125 // that produced it was summarized away.
126 func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {126 func repairOrphanedToolResults(msgs []provider.Message) []provider.Message {
127- // Collect all tool_use IDs present in the messages.127+ return provider.RepairOrphanedToolResults(msgs)
128- useIDs := map[string]bool{}
129- for _, m := range msgs {
130- for _, c := range m.Content {
131- if tc, ok := c.(provider.ToolCallBlock); ok {
132- useIDs[tc.ID] = true
133- }
134- }
135- }
136-
137- // Filter out tool_result blocks referencing missing tool_use IDs.
138- out := make([]provider.Message, 0, len(msgs))
139- for _, m := range msgs {
140- var filtered []provider.Content
141- for _, c := range m.Content {
142- if tr, ok := c.(provider.ToolResultBlock); ok {
143- if !useIDs[tr.CallID] {
144- continue // orphaned
145- }
146- }
147- filtered = append(filtered, c)
148- }
149- if len(filtered) > 0 {
150- copy := m
151- copy.Content = filtered
152- out = append(out, copy)
153- }
154- // Drop messages that became empty after filtering.
155- }
156- return out
157 }128 }
158 129
159 // serializeTranscript renders a list of provider.Message into a plain130 // serializeTranscript renders a list of provider.Message into a plain
M internal/core/session.go +15 -6
@@ -594,12 +594,15 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
594594 msg := provider.Message{Role: row.Message.Role, Time: row.Message.Time}
595595 for _, raw := range row.Message.Content {
596596 var head struct {
597- Text string `json:"text"`
598- MimeType string `json:"mime_type"`
599- Data []byte `json:"data"`
600- ID string `json:"id"`
601- Name string `json:"name"`
602- CallID string `json:"call_id"`
597+ Text string `json:"text"`
598+ MimeType string `json:"mime_type"`
599+ Data []byte `json:"data"`
600+ ID string `json:"id"`
601+ Name string `json:"name"`
602+ CallID string `json:"call_id"`
603+ ReasoningID string `json:"reasoning_id"`
604+ Summary string `json:"summary"`
605+ Encrypted string `json:"encrypted_content"`
603606 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
604607 }
605608 if err := json.Unmarshal(raw, &head); err != nil {
@@ -607,6 +610,12 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
607610 }
608611 // Discriminate by presence of fields.
609612 switch {
613+ case head.ReasoningID != "" || head.Encrypted != "":
614+ msg.Content = append(msg.Content, provider.ReasoningBlock{
615+ ID: head.ReasoningID,
616+ Summary: head.Summary,
617+ Encrypted: head.Encrypted,
618+ })
610619 case head.Name != "" && head.ID != "":
611620 var tc struct {
612621 ID string `json:"id"`
@@ -594,12 +594,15 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
594 msg := provider.Message{Role: row.Message.Role, Time: row.Message.Time}594 msg := provider.Message{Role: row.Message.Role, Time: row.Message.Time}
595 for _, raw := range row.Message.Content {595 for _, raw := range row.Message.Content {
596 var head struct {596 var head struct {
597- Text string `json:"text"`597+ Text string `json:"text"`
598- MimeType string `json:"mime_type"`598+ MimeType string `json:"mime_type"`
599- Data []byte `json:"data"`599+ Data []byte `json:"data"`
600- ID string `json:"id"`600+ ID string `json:"id"`
601- Name string `json:"name"`601+ Name string `json:"name"`
602- CallID string `json:"call_id"`602+ CallID string `json:"call_id"`
603+ ReasoningID string `json:"reasoning_id"`
604+ Summary string `json:"summary"`
605+ Encrypted string `json:"encrypted_content"`
603 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError606 // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError
604 }607 }
605 if err := json.Unmarshal(raw, &head); err != nil {608 if err := json.Unmarshal(raw, &head); err != nil {
@@ -607,6 +610,12 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) {
607 }610 }
608 // Discriminate by presence of fields.611 // Discriminate by presence of fields.
609 switch {612 switch {
613+ case head.ReasoningID != "" || head.Encrypted != "":
614+ msg.Content = append(msg.Content, provider.ReasoningBlock{
615+ ID: head.ReasoningID,
616+ Summary: head.Summary,
617+ Encrypted: head.Encrypted,
618+ })
610 case head.Name != "" && head.ID != "":619 case head.Name != "" && head.ID != "":
611 var tc struct {620 var tc struct {
612 ID string `json:"id"`621 ID string `json:"id"`
M internal/provider/anthropic.go +2 -1
@@ -262,6 +262,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
262262 // emitting them separately keeps each message bit-stable across
263263 // turns, so the cache prefix matches for the entire history up
264264 // to the newest block.
265+ req.Messages = RepairOrphanedToolResults(req.Messages)
265266 for _, msg := range req.Messages {
266267 renameTools := c.oauthTok != ""
267268 switch msg.Role {
@@ -363,7 +364,7 @@ func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
363364 })
364365 case ToolCallBlock:
365366 args := v.Arguments
366- if len(args) == 0 {
367+ if len(args) == 0 || !json.Valid(args) {
367368 args = json.RawMessage("{}")
368369 }
369370 name := v.Name
@@ -262,6 +262,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) {
262 // emitting them separately keeps each message bit-stable across262 // emitting them separately keeps each message bit-stable across
263 // turns, so the cache prefix matches for the entire history up263 // turns, so the cache prefix matches for the entire history up
264 // to the newest block.264 // to the newest block.
265+ req.Messages = RepairOrphanedToolResults(req.Messages)
265 for _, msg := range req.Messages {266 for _, msg := range req.Messages {
266 renameTools := c.oauthTok != ""267 renameTools := c.oauthTok != ""
267 switch msg.Role {268 switch msg.Role {
@@ -363,7 +364,7 @@ func convertAnthContent(blocks []Content, renameTools bool) []interface{} {
363 })364 })
364 case ToolCallBlock:365 case ToolCallBlock:
365 args := v.Arguments366 args := v.Arguments
366- if len(args) == 0 {367+ if len(args) == 0 || !json.Valid(args) {
367 args = json.RawMessage("{}")368 args = json.RawMessage("{}")
368 }369 }
369 name := v.Name370 name := v.Name
M internal/provider/openai.go +1 -0
@@ -177,6 +177,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
177177 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
178178 }
179179
180+ req.Messages = RepairOrphanedToolResults(req.Messages)
180181 for _, msg := range req.Messages {
181182 switch msg.Role {
182183 case RoleUser:
@@ -177,6 +177,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) {
177 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})177 out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System})
178 }178 }
179 179
180+ req.Messages = RepairOrphanedToolResults(req.Messages)
180 for _, msg := range req.Messages {181 for _, msg := range req.Messages {
181 switch msg.Role {182 switch msg.Role {
182 case RoleUser:183 case RoleUser:
M internal/provider/openai_codex.go +101 -11
@@ -106,6 +106,24 @@ type codexFunctionCallOutput struct {
106106 Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
107107 }
108108
109+// codexReasoningItem mirrors the Responses API "reasoning" output item.
110+// We capture it on incoming streams and replay it verbatim on follow-up
111+// requests: the API rejects assistant tool-call replays without it when
112+// thinking is enabled.
113+type codexReasoningItem struct {
114+ Type string `json:"type"` // "reasoning"
115+ ID string `json:"id,omitempty"`
116+ EncryptedContent string `json:"encrypted_content,omitempty"`
117+ // Summary is required by the Responses API even when no summary text
118+ // was streamed; encode an empty array rather than omitting the field.
119+ Summary []codexReasoningSummary `json:"summary"`
120+}
121+
122+type codexReasoningSummary struct {
123+ Type string `json:"type"` // "summary_text"
124+ Text string `json:"text"`
125+}
126+
109127 type codexTool struct {
110128 Type string `json:"type"` // "function"
111129 Name string `json:"name"`
@@ -159,6 +177,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
159177 }
160178
161179 msgIdx := 0
180+ req.Messages = RepairOrphanedToolResults(req.Messages)
162181 for _, msg := range req.Messages {
163182 switch msg.Role {
164183 case RoleUser:
@@ -179,10 +198,25 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
179198 }
180199 body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
181200 case RoleAssistant:
182- // Emit one output_message per text block and one function_call per tool call,
183- // preserving the order so model sees the same interleaving we captured.
201+ // Emit one output_message per text block, one function_call per
202+ // tool call, and one reasoning item per ReasoningBlock,
203+ // preserving the order so the model sees the same interleaving
204+ // we captured. The reasoning replay is what keeps OpenAI
205+ // Codex from rejecting follow-up tool calls with
206+ // "thinking is enabled but reasoning_content is missing".
184207 for _, c := range msg.Content {
185208 switch v := c.(type) {
209+ case ReasoningBlock:
210+ item := codexReasoningItem{
211+ Type: "reasoning",
212+ ID: v.ID,
213+ EncryptedContent: v.Encrypted,
214+ Summary: []codexReasoningSummary{},
215+ }
216+ if v.Summary != "" {
217+ item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
218+ }
219+ body.Input = append(body.Input, item)
186220 case TextBlock:
187221 if v.Text == "" {
188222 continue
@@ -199,7 +233,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
199233 })
200234 case ToolCallBlock:
201235 args := string(v.Arguments)
202- if args == "" {
236+ if args == "" || !json.Valid([]byte(args)) {
203237 args = "{}"
204238 }
205239 callID, _ := splitCallID(v.ID)
@@ -297,11 +331,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
297331 // item is either a "message" (text) or a "function_call". We track
298332 // the in-flight item by its index.
299333 type itemState struct {
300- kind string // "message" | "function_call"
334+ kind string // "message" | "function_call" | "reasoning"
301335 callID string
302336 name string
303337 argsBuf strings.Builder
304338 textBuf strings.Builder
339+ summary strings.Builder
340+ rawID string
341+ encrypted string
305342 announced bool
306343 }
307344 var (
@@ -323,12 +360,21 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
323360 }
324361 case "function_call":
325362 args := it.argsBuf.String()
326- if args == "" {
363+ if args == "" || !json.Valid([]byte(args)) {
327364 args = "{}"
328365 }
329366 content = append(content, ToolCallBlock{
330367 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
331368 })
369+ case "reasoning":
370+ if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
371+ continue
372+ }
373+ content = append(content, ReasoningBlock{
374+ ID: it.rawID,
375+ Summary: it.summary.String(),
376+ Encrypted: it.encrypted,
377+ })
332378 }
333379 }
334380 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
@@ -364,10 +410,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
364410 var p struct {
365411 OutputIndex int `json:"output_index"`
366412 Item struct {
367- Type string `json:"type"` // "message" | "function_call"
368- ID string `json:"id"`
369- CallID string `json:"call_id"`
370- Name string `json:"name"`
413+ Type string `json:"type"` // "message" | "function_call" | "reasoning"
414+ ID string `json:"id"`
415+ CallID string `json:"call_id"`
416+ Name string `json:"name"`
417+ EncryptedContent string `json:"encrypted_content"`
371418 } `json:"item"`
372419 }
373420 _ = json.Unmarshal([]byte(ev.Data), &p)
@@ -383,6 +430,10 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
383430 it.announced = true
384431 out <- EventToolStart{ID: it.callID, Name: it.name}
385432 }
433+ case "reasoning":
434+ it.kind = "reasoning"
435+ it.rawID = p.Item.ID
436+ it.encrypted = p.Item.EncryptedContent
386437 default:
387438 continue
388439 }
@@ -398,6 +449,17 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
398449 it.textBuf.WriteString(p.Delta)
399450 out <- EventTextDelta{Delta: p.Delta}
400451 }
452+ case "response.reasoning_summary_text.delta":
453+ var p struct {
454+ OutputIndex int `json:"output_index"`
455+ Delta string `json:"delta"`
456+ }
457+ _ = json.Unmarshal([]byte(ev.Data), &p)
458+ if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
459+ it.summary.WriteString(p.Delta)
460+ }
461+ case "response.reasoning_summary_text.done":
462+ // summary text already accumulated via deltas
401463 case "response.function_call_arguments.delta":
402464 var p struct {
403465 OutputIndex int `json:"output_index"`
@@ -411,10 +473,38 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
411473 case "response.output_item.done":
412474 var p struct {
413475 OutputIndex int `json:"output_index"`
476+ Item struct {
477+ Type string `json:"type"`
478+ ID string `json:"id"`
479+ EncryptedContent string `json:"encrypted_content"`
480+ Summary []struct {
481+ Type string `json:"type"`
482+ Text string `json:"text"`
483+ } `json:"summary"`
484+ } `json:"item"`
414485 }
415486 _ = json.Unmarshal([]byte(ev.Data), &p)
416- if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {
417- out <- EventToolEnd{ID: it.callID}
487+ if it, ok := items[p.OutputIndex]; ok {
488+ switch it.kind {
489+ case "function_call":
490+ out <- EventToolEnd{ID: it.callID}
491+ case "reasoning":
492+ if p.Item.EncryptedContent != "" {
493+ it.encrypted = p.Item.EncryptedContent
494+ }
495+ if it.rawID == "" && p.Item.ID != "" {
496+ it.rawID = p.Item.ID
497+ }
498+ for _, s := range p.Item.Summary {
499+ if s.Text == "" {
500+ continue
501+ }
502+ if it.summary.Len() > 0 {
503+ it.summary.WriteString("\n")
504+ }
505+ it.summary.WriteString(s.Text)
506+ }
507+ }
418508 }
419509 case "response.completed", "response.done":
420510 var p struct {
@@ -106,6 +106,24 @@ type codexFunctionCallOutput struct {
106 Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)106 Output string `json:"output"` // string (or ResponseFunctionCallOutputItemList for images; v1 only uses string)
107 }107 }
108 108
109+// codexReasoningItem mirrors the Responses API "reasoning" output item.
110+// We capture it on incoming streams and replay it verbatim on follow-up
111+// requests: the API rejects assistant tool-call replays without it when
112+// thinking is enabled.
113+type codexReasoningItem struct {
114+ Type string `json:"type"` // "reasoning"
115+ ID string `json:"id,omitempty"`
116+ EncryptedContent string `json:"encrypted_content,omitempty"`
117+ // Summary is required by the Responses API even when no summary text
118+ // was streamed; encode an empty array rather than omitting the field.
119+ Summary []codexReasoningSummary `json:"summary"`
120+}
121+
122+type codexReasoningSummary struct {
123+ Type string `json:"type"` // "summary_text"
124+ Text string `json:"text"`
125+}
126+
109 type codexTool struct {127 type codexTool struct {
110 Type string `json:"type"` // "function"128 Type string `json:"type"` // "function"
111 Name string `json:"name"`129 Name string `json:"name"`
@@ -159,6 +177,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
159 }177 }
160 178
161 msgIdx := 0179 msgIdx := 0
180+ req.Messages = RepairOrphanedToolResults(req.Messages)
162 for _, msg := range req.Messages {181 for _, msg := range req.Messages {
163 switch msg.Role {182 switch msg.Role {
164 case RoleUser:183 case RoleUser:
@@ -179,10 +198,25 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
179 }198 }
180 body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})199 body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content})
181 case RoleAssistant:200 case RoleAssistant:
182- // Emit one output_message per text block and one function_call per tool call,201+ // Emit one output_message per text block, one function_call per
183- // preserving the order so model sees the same interleaving we captured.202+ // tool call, and one reasoning item per ReasoningBlock,
203+ // preserving the order so the model sees the same interleaving
204+ // we captured. The reasoning replay is what keeps OpenAI
205+ // Codex from rejecting follow-up tool calls with
206+ // "thinking is enabled but reasoning_content is missing".
184 for _, c := range msg.Content {207 for _, c := range msg.Content {
185 switch v := c.(type) {208 switch v := c.(type) {
209+ case ReasoningBlock:
210+ item := codexReasoningItem{
211+ Type: "reasoning",
212+ ID: v.ID,
213+ EncryptedContent: v.Encrypted,
214+ Summary: []codexReasoningSummary{},
215+ }
216+ if v.Summary != "" {
217+ item.Summary = []codexReasoningSummary{{Type: "summary_text", Text: v.Summary}}
218+ }
219+ body.Input = append(body.Input, item)
186 case TextBlock:220 case TextBlock:
187 if v.Text == "" {221 if v.Text == "" {
188 continue222 continue
@@ -199,7 +233,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) {
199 })233 })
200 case ToolCallBlock:234 case ToolCallBlock:
201 args := string(v.Arguments)235 args := string(v.Arguments)
202- if args == "" {236+ if args == "" || !json.Valid([]byte(args)) {
203 args = "{}"237 args = "{}"
204 }238 }
205 callID, _ := splitCallID(v.ID)239 callID, _ := splitCallID(v.ID)
@@ -297,11 +331,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
297 // item is either a "message" (text) or a "function_call". We track331 // item is either a "message" (text) or a "function_call". We track
298 // the in-flight item by its index.332 // the in-flight item by its index.
299 type itemState struct {333 type itemState struct {
300- kind string // "message" | "function_call"334+ kind string // "message" | "function_call" | "reasoning"
301 callID string335 callID string
302 name string336 name string
303 argsBuf strings.Builder337 argsBuf strings.Builder
304 textBuf strings.Builder338 textBuf strings.Builder
339+ summary strings.Builder
340+ rawID string
341+ encrypted string
305 announced bool342 announced bool
306 }343 }
307 var (344 var (
@@ -323,12 +360,21 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
323 }360 }
324 case "function_call":361 case "function_call":
325 args := it.argsBuf.String()362 args := it.argsBuf.String()
326- if args == "" {363+ if args == "" || !json.Valid([]byte(args)) {
327 args = "{}"364 args = "{}"
328 }365 }
329 content = append(content, ToolCallBlock{366 content = append(content, ToolCallBlock{
330 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),367 ID: it.callID, Name: it.name, Arguments: json.RawMessage(args),
331 })368 })
369+ case "reasoning":
370+ if it.encrypted == "" && it.summary.Len() == 0 && it.rawID == "" {
371+ continue
372+ }
373+ content = append(content, ReasoningBlock{
374+ ID: it.rawID,
375+ Summary: it.summary.String(),
376+ Encrypted: it.encrypted,
377+ })
332 }378 }
333 }379 }
334 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}380 return Message{Role: RoleAssistant, Content: content, Time: time.Now()}
@@ -364,10 +410,11 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
364 var p struct {410 var p struct {
365 OutputIndex int `json:"output_index"`411 OutputIndex int `json:"output_index"`
366 Item struct {412 Item struct {
367- Type string `json:"type"` // "message" | "function_call"413+ Type string `json:"type"` // "message" | "function_call" | "reasoning"
368- ID string `json:"id"`414+ ID string `json:"id"`
369- CallID string `json:"call_id"`415+ CallID string `json:"call_id"`
370- Name string `json:"name"`416+ Name string `json:"name"`
417+ EncryptedContent string `json:"encrypted_content"`
371 } `json:"item"`418 } `json:"item"`
372 }419 }
373 _ = json.Unmarshal([]byte(ev.Data), &p)420 _ = json.Unmarshal([]byte(ev.Data), &p)
@@ -383,6 +430,10 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
383 it.announced = true430 it.announced = true
384 out <- EventToolStart{ID: it.callID, Name: it.name}431 out <- EventToolStart{ID: it.callID, Name: it.name}
385 }432 }
433+ case "reasoning":
434+ it.kind = "reasoning"
435+ it.rawID = p.Item.ID
436+ it.encrypted = p.Item.EncryptedContent
386 default:437 default:
387 continue438 continue
388 }439 }
@@ -398,6 +449,17 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
398 it.textBuf.WriteString(p.Delta)449 it.textBuf.WriteString(p.Delta)
399 out <- EventTextDelta{Delta: p.Delta}450 out <- EventTextDelta{Delta: p.Delta}
400 }451 }
452+ case "response.reasoning_summary_text.delta":
453+ var p struct {
454+ OutputIndex int `json:"output_index"`
455+ Delta string `json:"delta"`
456+ }
457+ _ = json.Unmarshal([]byte(ev.Data), &p)
458+ if it, ok := items[p.OutputIndex]; ok && it.kind == "reasoning" {
459+ it.summary.WriteString(p.Delta)
460+ }
461+ case "response.reasoning_summary_text.done":
462+ // summary text already accumulated via deltas
401 case "response.function_call_arguments.delta":463 case "response.function_call_arguments.delta":
402 var p struct {464 var p struct {
403 OutputIndex int `json:"output_index"`465 OutputIndex int `json:"output_index"`
@@ -411,10 +473,38 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re
411 case "response.output_item.done":473 case "response.output_item.done":
412 var p struct {474 var p struct {
413 OutputIndex int `json:"output_index"`475 OutputIndex int `json:"output_index"`
476+ Item struct {
477+ Type string `json:"type"`
478+ ID string `json:"id"`
479+ EncryptedContent string `json:"encrypted_content"`
480+ Summary []struct {
481+ Type string `json:"type"`
482+ Text string `json:"text"`
483+ } `json:"summary"`
484+ } `json:"item"`
414 }485 }
415 _ = json.Unmarshal([]byte(ev.Data), &p)486 _ = json.Unmarshal([]byte(ev.Data), &p)
416- if it, ok := items[p.OutputIndex]; ok && it.kind == "function_call" {487+ if it, ok := items[p.OutputIndex]; ok {
417- out <- EventToolEnd{ID: it.callID}488+ switch it.kind {
489+ case "function_call":
490+ out <- EventToolEnd{ID: it.callID}
491+ case "reasoning":
492+ if p.Item.EncryptedContent != "" {
493+ it.encrypted = p.Item.EncryptedContent
494+ }
495+ if it.rawID == "" && p.Item.ID != "" {
496+ it.rawID = p.Item.ID
497+ }
498+ for _, s := range p.Item.Summary {
499+ if s.Text == "" {
500+ continue
501+ }
502+ if it.summary.Len() > 0 {
503+ it.summary.WriteString("\n")
504+ }
505+ it.summary.WriteString(s.Text)
506+ }
507+ }
418 }508 }
419 case "response.completed", "response.done":509 case "response.completed", "response.done":
420 var p struct {510 var p struct {
M internal/provider/provider.go +49 -0
@@ -60,6 +60,55 @@ type ToolResultBlock struct {
6060
6161 func (ToolResultBlock) isContent() {}
6262
63+// ReasoningBlock carries the assistant's chain-of-thought metadata so
64+// providers that require it on follow-up requests (OpenAI Codex with
65+// thinking enabled) can replay the same payload they emitted earlier.
66+// Summary is the human-readable reasoning summary (may be empty); the
67+// encrypted blob is opaque to zot. ID is the provider-issued reasoning
68+// item id.
69+type ReasoningBlock struct {
70+ ID string `json:"reasoning_id,omitempty"`
71+ Summary string `json:"summary,omitempty"`
72+ Encrypted string `json:"encrypted_content,omitempty"`
73+}
74+
75+func (ReasoningBlock) isContent() {}
76+
77+// RepairOrphanedToolResults removes tool_result content blocks (and
78+// entire messages that become empty) when the matching tool_use ID
79+// does not appear anywhere in the given messages. Resume tails,
80+// compaction repair, and provider request builders all need this so
81+// the upstream API never sees a tool_call_id with no corresponding
82+// assistant tool_call earlier in the same request.
83+func RepairOrphanedToolResults(msgs []Message) []Message {
84+ useIDs := map[string]bool{}
85+ for _, m := range msgs {
86+ for _, c := range m.Content {
87+ if tc, ok := c.(ToolCallBlock); ok {
88+ useIDs[tc.ID] = true
89+ }
90+ }
91+ }
92+ out := make([]Message, 0, len(msgs))
93+ for _, m := range msgs {
94+ var filtered []Content
95+ for _, c := range m.Content {
96+ if tr, ok := c.(ToolResultBlock); ok {
97+ if !useIDs[tr.CallID] {
98+ continue
99+ }
100+ }
101+ filtered = append(filtered, c)
102+ }
103+ if len(filtered) > 0 {
104+ copy := m
105+ copy.Content = filtered
106+ out = append(out, copy)
107+ }
108+ }
109+ return out
110+}
111+
63112 // Message is a single turn in the conversation.
64113 type Message struct {
65114 Role Role `json:"role"`
@@ -60,6 +60,55 @@ type ToolResultBlock struct {
60 60
61 func (ToolResultBlock) isContent() {}61 func (ToolResultBlock) isContent() {}
62 62
63+// ReasoningBlock carries the assistant's chain-of-thought metadata so
64+// providers that require it on follow-up requests (OpenAI Codex with
65+// thinking enabled) can replay the same payload they emitted earlier.
66+// Summary is the human-readable reasoning summary (may be empty); the
67+// encrypted blob is opaque to zot. ID is the provider-issued reasoning
68+// item id.
69+type ReasoningBlock struct {
70+ ID string `json:"reasoning_id,omitempty"`
71+ Summary string `json:"summary,omitempty"`
72+ Encrypted string `json:"encrypted_content,omitempty"`
73+}
74+
75+func (ReasoningBlock) isContent() {}
76+
77+// RepairOrphanedToolResults removes tool_result content blocks (and
78+// entire messages that become empty) when the matching tool_use ID
79+// does not appear anywhere in the given messages. Resume tails,
80+// compaction repair, and provider request builders all need this so
81+// the upstream API never sees a tool_call_id with no corresponding
82+// assistant tool_call earlier in the same request.
83+func RepairOrphanedToolResults(msgs []Message) []Message {
84+ useIDs := map[string]bool{}
85+ for _, m := range msgs {
86+ for _, c := range m.Content {
87+ if tc, ok := c.(ToolCallBlock); ok {
88+ useIDs[tc.ID] = true
89+ }
90+ }
91+ }
92+ out := make([]Message, 0, len(msgs))
93+ for _, m := range msgs {
94+ var filtered []Content
95+ for _, c := range m.Content {
96+ if tr, ok := c.(ToolResultBlock); ok {
97+ if !useIDs[tr.CallID] {
98+ continue
99+ }
100+ }
101+ filtered = append(filtered, c)
102+ }
103+ if len(filtered) > 0 {
104+ copy := m
105+ copy.Content = filtered
106+ out = append(out, copy)
107+ }
108+ }
109+ return out
110+}
111+
63 // Message is a single turn in the conversation.112 // Message is a single turn in the conversation.
64 type Message struct {113 type Message struct {
65 Role Role `json:"role"`114 Role Role `json:"role"`
M internal/tui/render.go +21 -12
@@ -302,16 +302,27 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
302302 w.WriteString(line)
303303 w.WriteString("\r\n")
304304 }
305- w.WriteString(SeqSaveCursor)
306305 writeBlock(&w, bottomFrame)
307306 r.logInit = true
308307 } else {
309- // Return to the saved top-of-bottom-band anchor instead of relying
310- // on relative cursor movement from the last exposed editor cursor.
311- // If the terminal naturally scrolled between frames, save/restore is
312- // less prone to drift that leaves duplicated transcript blocks until
313- // ctrl+l forces a clear repaint.
314- w.WriteString(SeqRestoreCursor)
308+ // Walk back up to the top of the previous bottom block. The cursor
309+ // was last positioned somewhere inside the bottom band by the
310+ // previous Draw (final ShowCursor below); we don't trust the
311+ // terminal's saved cursor across frames because terminal-driven
312+ // scrolling would invalidate it. Instead, rebuild the relative
313+ // position from r.cursorRow inside the previous bottomFrame.
314+ prevBottomRows := len(r.logBottom)
315+ prevCursorRow := r.cursorRow
316+ if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {
317+ prevCursorRow = prevBottomRows - 1
318+ if prevCursorRow < 0 {
319+ prevCursorRow = 0
320+ }
321+ }
322+ up := prevCursorRow
323+ if prevBottomRows > 0 && up > 0 {
324+ w.WriteString("\x1b[" + itoa(up) + "A")
325+ }
315326 w.WriteString("\r")
316327
317328 prefix := len(r.logChat) <= len(chatFrame)
@@ -324,10 +335,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
324335 }
325336 }
326337 if prefix {
327- // Erase old bottom (and anything below the saved anchor), then
328- // append only genuinely new chat rows. They become real terminal
329- // scrollback, and inline image escapes are emitted once here — not
330- // on every keystroke.
338+ // Erase old bottom band entirely, then append only genuinely new
339+ // chat rows above the new bottom band. New chat rows become real
340+ // terminal scrollback; inline image escapes are emitted once here.
331341 w.WriteString(SeqEraseToEnd)
332342 for _, line := range chatFrame[len(r.logChat):] {
333343 w.WriteString("\x1b[0m")
@@ -354,7 +364,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
354364 w.WriteString("\r\n")
355365 }
356366 }
357- w.WriteString(SeqSaveCursor)
358367 writeBlock(&w, bottomFrame)
359368 }
360369
@@ -302,16 +302,27 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
302 w.WriteString(line)302 w.WriteString(line)
303 w.WriteString("\r\n")303 w.WriteString("\r\n")
304 }304 }
305- w.WriteString(SeqSaveCursor)
306 writeBlock(&w, bottomFrame)305 writeBlock(&w, bottomFrame)
307 r.logInit = true306 r.logInit = true
308 } else {307 } else {
309- // Return to the saved top-of-bottom-band anchor instead of relying308+ // Walk back up to the top of the previous bottom block. The cursor
310- // on relative cursor movement from the last exposed editor cursor.309+ // was last positioned somewhere inside the bottom band by the
311- // If the terminal naturally scrolled between frames, save/restore is310+ // previous Draw (final ShowCursor below); we don't trust the
312- // less prone to drift that leaves duplicated transcript blocks until311+ // terminal's saved cursor across frames because terminal-driven
313- // ctrl+l forces a clear repaint.312+ // scrolling would invalidate it. Instead, rebuild the relative
314- w.WriteString(SeqRestoreCursor)313+ // position from r.cursorRow inside the previous bottomFrame.
314+ prevBottomRows := len(r.logBottom)
315+ prevCursorRow := r.cursorRow
316+ if prevCursorRow < 0 || prevCursorRow >= prevBottomRows {
317+ prevCursorRow = prevBottomRows - 1
318+ if prevCursorRow < 0 {
319+ prevCursorRow = 0
320+ }
321+ }
322+ up := prevCursorRow
323+ if prevBottomRows > 0 && up > 0 {
324+ w.WriteString("\x1b[" + itoa(up) + "A")
325+ }
315 w.WriteString("\r")326 w.WriteString("\r")
316 327
317 prefix := len(r.logChat) <= len(chatFrame)328 prefix := len(r.logChat) <= len(chatFrame)
@@ -324,10 +335,9 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
324 }335 }
325 }336 }
326 if prefix {337 if prefix {
327- // Erase old bottom (and anything below the saved anchor), then338+ // Erase old bottom band entirely, then append only genuinely new
328- // append only genuinely new chat rows. They become real terminal339+ // chat rows above the new bottom band. New chat rows become real
329- // scrollback, and inline image escapes are emitted once here — not340+ // terminal scrollback; inline image escapes are emitted once here.
330- // on every keystroke.
331 w.WriteString(SeqEraseToEnd)341 w.WriteString(SeqEraseToEnd)
332 for _, line := range chatFrame[len(r.logChat):] {342 for _, line := range chatFrame[len(r.logChat):] {
333 w.WriteString("\x1b[0m")343 w.WriteString("\x1b[0m")
@@ -354,7 +364,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int
354 w.WriteString("\r\n")364 w.WriteString("\r\n")
355 }365 }
356 }366 }
357- w.WriteString(SeqSaveCursor)
358 writeBlock(&w, bottomFrame)367 writeBlock(&w, bottomFrame)
359 }368 }
360 369
M internal/tui/view.go +33 -0
@@ -20,6 +20,38 @@ func expandTabs(s string) string {
2020 return strings.ReplaceAll(s, "\t", " ")
2121 }
2222
23+// sanitizeUserBubbleLine prepares a single user-bubble row for safe
24+// rendering. Pasted content from another terminal can contain
25+// embedded ANSI escape sequences, control bytes, and tabs that
26+// either reset the bubble's background colour or move the cursor in
27+// ways that break the bubble's painted column.
28+func sanitizeUserBubbleLine(s string) string {
29+ if s == "" {
30+ return s
31+ }
32+ s = expandTabs(s)
33+ var b strings.Builder
34+ b.Grow(len(s))
35+ for i := 0; i < len(s); {
36+ c := s[i]
37+ if c == 0x1b { // ESC: drop CSI/OSC/DCS and simple escapes.
38+ i = skipEscapeSequence(s, i)
39+ continue
40+ }
41+ if c == '\r' || c == '\b' || c == 0x07 {
42+ i++
43+ continue
44+ }
45+ if c < 0x20 || c == 0x7f {
46+ i++
47+ continue
48+ }
49+ b.WriteByte(c)
50+ i++
51+ }
52+ return b.String()
53+}
54+
2355 // pathFromToolArgs returns the "path" argument from a tool_call's
2456 // JSON arguments, or "" if the args aren't a JSON object or don't
2557 // include one. Used to pick a syntax language for rendering the
@@ -540,6 +572,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
540572 switch b := c.(type) {
541573 case provider.TextBlock:
542574 for _, l := range strings.Split(b.Text, "\n") {
575+ l = sanitizeUserBubbleLine(l)
543576 for _, w := range wrapLine(l, innerWidth, "") {
544577 bubble = append(bubble, row(w))
545578 }
@@ -20,6 +20,38 @@ func expandTabs(s string) string {
20 return strings.ReplaceAll(s, "\t", " ")20 return strings.ReplaceAll(s, "\t", " ")
21 }21 }
22 22
23+// sanitizeUserBubbleLine prepares a single user-bubble row for safe
24+// rendering. Pasted content from another terminal can contain
25+// embedded ANSI escape sequences, control bytes, and tabs that
26+// either reset the bubble's background colour or move the cursor in
27+// ways that break the bubble's painted column.
28+func sanitizeUserBubbleLine(s string) string {
29+ if s == "" {
30+ return s
31+ }
32+ s = expandTabs(s)
33+ var b strings.Builder
34+ b.Grow(len(s))
35+ for i := 0; i < len(s); {
36+ c := s[i]
37+ if c == 0x1b { // ESC: drop CSI/OSC/DCS and simple escapes.
38+ i = skipEscapeSequence(s, i)
39+ continue
40+ }
41+ if c == '\r' || c == '\b' || c == 0x07 {
42+ i++
43+ continue
44+ }
45+ if c < 0x20 || c == 0x7f {
46+ i++
47+ continue
48+ }
49+ b.WriteByte(c)
50+ i++
51+ }
52+ return b.String()
53+}
54+
23 // pathFromToolArgs returns the "path" argument from a tool_call's55 // pathFromToolArgs returns the "path" argument from a tool_call's
24 // JSON arguments, or "" if the args aren't a JSON object or don't56 // JSON arguments, or "" if the args aren't a JSON object or don't
25 // include one. Used to pick a syntax language for rendering the57 // include one. Used to pick a syntax language for rendering the
@@ -540,6 +572,7 @@ func (v *View) renderMessage(m provider.Message, width int, turnOpen bool) []str
540 switch b := c.(type) {572 switch b := c.(type) {
541 case provider.TextBlock:573 case provider.TextBlock:
542 for _, l := range strings.Split(b.Text, "\n") {574 for _, l := range strings.Split(b.Text, "\n") {
575+ l = sanitizeUserBubbleLine(l)
543 for _, w := range wrapLine(l, innerWidth, "") {576 for _, w := range wrapLine(l, innerWidth, "") {
544 bubble = append(bubble, row(w))577 bubble = append(bubble, row(w))
545 }578 }

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