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 { | |||
| 113 | 113 | // dropped to keep the per-extension stream sane. | |
| 114 | 114 | func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Message { | |
| 115 | 115 | if keepTail <= 0 || len(msgs) <= keepTail { | |
| 116 | - | return msgs | |
| 116 | + | return provider.RepairOrphanedToolResults(msgs) | |
| 117 | 117 | } | |
| 118 | 118 | var out []provider.Message | |
| 119 | 119 | start := len(msgs) - keepTail | |
| @@ -128,7 +128,7 @@ func trimMessagesForResume(msgs []provider.Message, keepTail int) []provider.Mes | |||
| 128 | 128 | start++ | |
| 129 | 129 | } | |
| 130 | 130 | out = append(out, msgs[start:]...) | |
| 131 | - | return out | |
| 131 | + | return provider.RepairOrphanedToolResults(out) | |
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | func fanoutAgentEvent(mgr *extensions.Manager, ev core.AgentEvent) { | |
| @@ -586,7 +586,7 @@ func runInteractive(ctx context.Context, args Args, version string) error { | |||
| 586 | 586 | return err | |
| 587 | 587 | } | |
| 588 | 588 | fullMsgCount := len(msgs) | |
| 589 | - | msgs = trimMessagesForResume(msgs, 20) | |
| 589 | + | msgs = trimMessagesForResume(msgs, 100) | |
| 590 | 590 | persistMu.Lock() | |
| 591 | 591 | // Flush any unsaved messages to the old session before swapping. | |
| 592 | 592 | // 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 msgs | 116 | + | return provider.RepairOrphanedToolResults(msgs) |
| 117 | } | 117 | } | ||
| 118 | var out []provider.Message | 118 | var out []provider.Message | ||
| 119 | start := len(msgs) - keepTail | 119 | 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 out | 131 | + | 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 err | 586 | 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, so | 592 | // 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 | |||
| 124 | 124 | // compaction when the tail preserves a tool_result but the tool_use | |
| 125 | 125 | // that produced it was summarized away. | |
| 126 | 126 | 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) | |
| 157 | 128 | } | |
| 158 | 129 | ||
| 159 | 130 | // 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_use | 124 | // 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 plain | 130 | // 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) { | |||
| 594 | 594 | msg := provider.Message{Role: row.Message.Role, Time: row.Message.Time} | |
| 595 | 595 | for _, raw := range row.Message.Content { | |
| 596 | 596 | 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"` | |
| 603 | 606 | // ToolCallBlock also has Arguments, ToolResultBlock has Content + IsError | |
| 604 | 607 | } | |
| 605 | 608 | if err := json.Unmarshal(raw, &head); err != nil { | |
| @@ -607,6 +610,12 @@ func hydrateMessage(lineBytes []byte) (provider.Message, error) { | |||
| 607 | 610 | } | |
| 608 | 611 | // Discriminate by presence of fields. | |
| 609 | 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 | 619 | case head.Name != "" && head.ID != "": | |
| 611 | 620 | var tc struct { | |
| 612 | 621 | 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 + IsError | 606 | // 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) { | |||
| 262 | 262 | // emitting them separately keeps each message bit-stable across | |
| 263 | 263 | // turns, so the cache prefix matches for the entire history up | |
| 264 | 264 | // to the newest block. | |
| 265 | + | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 265 | 266 | for _, msg := range req.Messages { | |
| 266 | 267 | renameTools := c.oauthTok != "" | |
| 267 | 268 | switch msg.Role { | |
| @@ -363,7 +364,7 @@ func convertAnthContent(blocks []Content, renameTools bool) []interface{} { | |||
| 363 | 364 | }) | |
| 364 | 365 | case ToolCallBlock: | |
| 365 | 366 | args := v.Arguments | |
| 366 | - | if len(args) == 0 { | |
| 367 | + | if len(args) == 0 || !json.Valid(args) { | |
| 367 | 368 | args = json.RawMessage("{}") | |
| 368 | 369 | } | |
| 369 | 370 | name := v.Name | |
| @@ -262,6 +262,7 @@ func (c *anthropicClient) buildRequest(req Request) (*anthRequest, error) { | |||||
| 262 | // emitting them separately keeps each message bit-stable across | 262 | // emitting them separately keeps each message bit-stable across | ||
| 263 | // turns, so the cache prefix matches for the entire history up | 263 | // 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.Arguments | 366 | 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.Name | 370 | name := v.Name | ||
M internal/provider/openai.go +1 -0
| @@ -177,6 +177,7 @@ func (c *openaiClient) buildRequest(req Request) (*oaiRequest, error) { | |||
| 177 | 177 | out.Messages = append(out.Messages, oaiMessage{Role: "system", Content: req.System}) | |
| 178 | 178 | } | |
| 179 | 179 | ||
| 180 | + | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 180 | 181 | for _, msg := range req.Messages { | |
| 181 | 182 | switch msg.Role { | |
| 182 | 183 | 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 { | |||
| 106 | 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 | 127 | type codexTool struct { | |
| 110 | 128 | Type string `json:"type"` // "function" | |
| 111 | 129 | Name string `json:"name"` | |
| @@ -159,6 +177,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 159 | 177 | } | |
| 160 | 178 | ||
| 161 | 179 | msgIdx := 0 | |
| 180 | + | req.Messages = RepairOrphanedToolResults(req.Messages) | |
| 162 | 181 | for _, msg := range req.Messages { | |
| 163 | 182 | switch msg.Role { | |
| 164 | 183 | case RoleUser: | |
| @@ -179,10 +198,25 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 179 | 198 | } | |
| 180 | 199 | body.Input = append(body.Input, codexInputMessage{Role: "user", Content: content}) | |
| 181 | 200 | 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". | |
| 184 | 207 | for _, c := range msg.Content { | |
| 185 | 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 | 220 | case TextBlock: | |
| 187 | 221 | if v.Text == "" { | |
| 188 | 222 | continue | |
| @@ -199,7 +233,7 @@ func (c *codexClient) buildRequest(req Request) (*codexRequest, error) { | |||
| 199 | 233 | }) | |
| 200 | 234 | case ToolCallBlock: | |
| 201 | 235 | args := string(v.Arguments) | |
| 202 | - | if args == "" { | |
| 236 | + | if args == "" || !json.Valid([]byte(args)) { | |
| 203 | 237 | args = "{}" | |
| 204 | 238 | } | |
| 205 | 239 | callID, _ := splitCallID(v.ID) | |
| @@ -297,11 +331,14 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 297 | 331 | // item is either a "message" (text) or a "function_call". We track | |
| 298 | 332 | // the in-flight item by its index. | |
| 299 | 333 | type itemState struct { | |
| 300 | - | kind string // "message" | "function_call" | |
| 334 | + | kind string // "message" | "function_call" | "reasoning" | |
| 301 | 335 | callID string | |
| 302 | 336 | name string | |
| 303 | 337 | argsBuf strings.Builder | |
| 304 | 338 | textBuf strings.Builder | |
| 339 | + | summary strings.Builder | |
| 340 | + | rawID string | |
| 341 | + | encrypted string | |
| 305 | 342 | announced bool | |
| 306 | 343 | } | |
| 307 | 344 | var ( | |
| @@ -323,12 +360,21 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 323 | 360 | } | |
| 324 | 361 | case "function_call": | |
| 325 | 362 | args := it.argsBuf.String() | |
| 326 | - | if args == "" { | |
| 363 | + | if args == "" || !json.Valid([]byte(args)) { | |
| 327 | 364 | args = "{}" | |
| 328 | 365 | } | |
| 329 | 366 | content = append(content, ToolCallBlock{ | |
| 330 | 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 | 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 | 410 | var p struct { | |
| 365 | 411 | OutputIndex int `json:"output_index"` | |
| 366 | 412 | 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"` | |
| 371 | 418 | } `json:"item"` | |
| 372 | 419 | } | |
| 373 | 420 | _ = json.Unmarshal([]byte(ev.Data), &p) | |
| @@ -383,6 +430,10 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 383 | 430 | it.announced = true | |
| 384 | 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 | 437 | default: | |
| 387 | 438 | continue | |
| 388 | 439 | } | |
| @@ -398,6 +449,17 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 398 | 449 | it.textBuf.WriteString(p.Delta) | |
| 399 | 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 | 463 | case "response.function_call_arguments.delta": | |
| 402 | 464 | var p struct { | |
| 403 | 465 | OutputIndex int `json:"output_index"` | |
| @@ -411,10 +473,38 @@ func (c *codexClient) runStream(ctx context.Context, resp *http.Response, req Re | |||
| 411 | 473 | case "response.output_item.done": | |
| 412 | 474 | var p struct { | |
| 413 | 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 | 486 | _ = 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 | + | } | |
| 418 | 508 | } | |
| 419 | 509 | case "response.completed", "response.done": | |
| 420 | 510 | 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 := 0 | 179 | 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 | continue | 222 | 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 track | 331 | // 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 string | 335 | callID string | ||
| 302 | name string | 336 | name string | ||
| 303 | argsBuf strings.Builder | 337 | argsBuf strings.Builder | ||
| 304 | textBuf strings.Builder | 338 | textBuf strings.Builder | ||
| 339 | + | summary strings.Builder | |||
| 340 | + | rawID string | |||
| 341 | + | encrypted string | |||
| 305 | announced bool | 342 | 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 = true | 430 | 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 | continue | 438 | 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 { | |||
| 60 | 60 | ||
| 61 | 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 | 112 | // Message is a single turn in the conversation. | |
| 64 | 113 | type Message struct { | |
| 65 | 114 | 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 | |||
| 302 | 302 | w.WriteString(line) | |
| 303 | 303 | w.WriteString("\r\n") | |
| 304 | 304 | } | |
| 305 | - | w.WriteString(SeqSaveCursor) | |
| 306 | 305 | writeBlock(&w, bottomFrame) | |
| 307 | 306 | r.logInit = true | |
| 308 | 307 | } 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 | + | } | |
| 315 | 326 | w.WriteString("\r") | |
| 316 | 327 | ||
| 317 | 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 | 337 | 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. | |
| 331 | 341 | w.WriteString(SeqEraseToEnd) | |
| 332 | 342 | for _, line := range chatFrame[len(r.logChat):] { | |
| 333 | 343 | w.WriteString("\x1b[0m") | |
| @@ -354,7 +364,6 @@ func (r *Renderer) DrawLog(chat, bottom []string, cursorBottomRow, cursorCol int | |||
| 354 | 364 | w.WriteString("\r\n") | |
| 355 | 365 | } | |
| 356 | 366 | } | |
| 357 | - | w.WriteString(SeqSaveCursor) | |
| 358 | 367 | writeBlock(&w, bottomFrame) | |
| 359 | 368 | } | |
| 360 | 369 | ||
| @@ -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 = true | 306 | r.logInit = true | ||
| 308 | } else { | 307 | } else { | ||
| 309 | - | // Return to the saved top-of-bottom-band anchor instead of relying | 308 | + | // 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 is | 310 | + | // previous Draw (final ShowCursor below); we don't trust the |
| 312 | - | // less prone to drift that leaves duplicated transcript blocks until | 311 | + | // 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), then | 338 | + | // Erase old bottom band entirely, then append only genuinely new |
| 328 | - | // append only genuinely new chat rows. They become real terminal | 339 | + | // chat rows above the new bottom band. New chat rows become real |
| 329 | - | // scrollback, and inline image escapes are emitted once here — not | 340 | + | // 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 { | |||
| 20 | 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 | 55 | // pathFromToolArgs returns the "path" argument from a tool_call's | |
| 24 | 56 | // JSON arguments, or "" if the args aren't a JSON object or don't | |
| 25 | 57 | // 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 | 572 | switch b := c.(type) { | |
| 541 | 573 | case provider.TextBlock: | |
| 542 | 574 | for _, l := range strings.Split(b.Text, "\n") { | |
| 575 | + | l = sanitizeUserBubbleLine(l) | |
| 543 | 576 | for _, w := range wrapLine(l, innerWidth, "") { | |
| 544 | 577 | bubble = append(bubble, row(w)) | |
| 545 | 578 | } | |
| @@ -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's | 55 | // pathFromToolArgs returns the "path" argument from a tool_call's | ||
| 24 | // JSON arguments, or "" if the args aren't a JSON object or don't | 56 | // 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 the | 57 | // 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 | } | ||