fix(core): repair orphan tool_use rows on session load
Resuming a session whose transcript contains an assistant
tool_use block without a matching tool_result in the next
message caused Anthropic (and OpenAI's responses API) to
refuse the first request with:
http 400: messages.N: `tool_use` ids were found without
`tool_result` blocks immediately after: toolu_...
Two ways the corrupt state gets onto disk:
- Older zot builds persisted the assistant tool_use row
before the tool_result row, then crashed or were killed
between the two writes.
- Earlier abort paths didn't drop the mid-turn assistant
message cleanly before it reached the session file.
OpenSession now passes the hydrated message slice through
repairToolUseResultPairs before returning it. For every
assistant tool_use whose id isn't covered by a tool_result in
the next message, the repair injects a stub
ToolResultBlock{
CallID: <id>,
Content: [TextBlock{"tool call was aborted; no result recorded."}],
IsError: true,
}
The stub is merged into the following tool-role message if
one exists (preserves row count), otherwise a new tool-role
message is inserted right after the assistant. Model sees
the aborted context and decides whether to retry.
Runs once per OpenSession call; the hot runtime path is
untouched. Live abort handling already drops partial
assistant messages, so this is purely a safety net for
legacy-corrupted files and the crash-between-writes case.
Tests in session_repair_test.go cover:
- stub appended when no tool-role message follows
- stub merged into partial tool-role message
- valid transcripts pass through unchanged
- nil/empty input handled safely- commit
- c16e929f32e949f4ac9dbe36f0bd7f292927c453
- author
- patriceckhart · · 2026-04-21 17:36 UTC
- parent
- dad49e9
- signature
- unsigned
- refs
- tag: v0.0.85
2 files · +195 -0
M internal/core/session.go +91 -0
| @@ -143,6 +143,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||
| 143 | 143 | if err := sc.Err(); err != nil { | |
| 144 | 144 | return nil, nil, err | |
| 145 | 145 | } | |
| 146 | + | messages = repairToolUseResultPairs(messages) | |
| 146 | 147 | out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) | |
| 147 | 148 | if err != nil { | |
| 148 | 149 | return nil, nil, err | |
| @@ -151,6 +152,96 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||
| 151 | 152 | return s, messages, nil | |
| 152 | 153 | } | |
| 153 | 154 | ||
| 155 | + | // repairToolUseResultPairs walks a restored transcript and | |
| 156 | + | // synthesises stub tool_result blocks for any assistant | |
| 157 | + | // tool_use blocks that aren't paired with a matching result in | |
| 158 | + | // the next message. Anthropic (and OpenAI via the responses API) | |
| 159 | + | // reject any request whose transcript leaves a tool_use without | |
| 160 | + | // its matching tool_result immediately after, with errors like: | |
| 161 | + | // | |
| 162 | + | // messages.8: `tool_use` ids were found without `tool_result` | |
| 163 | + | // blocks immediately after | |
| 164 | + | // | |
| 165 | + | // Corruption gets into the transcript two ways we know of: | |
| 166 | + | // | |
| 167 | + | // - Older zot builds that persisted the assistant tool_use row | |
| 168 | + | // before the tool_result row, then crashed between the two. | |
| 169 | + | // - Abort paths in older builds that didn't drop the mid-turn | |
| 170 | + | // assistant message cleanly. | |
| 171 | + | // | |
| 172 | + | // Rather than change runtime semantics (which would risk hiding a | |
| 173 | + | // real bug), we scrub on load: any unmatched tool_use gets a stub | |
| 174 | + | // tool_result injected as a RoleTool message so the next | |
| 175 | + | // outbound request passes the provider's validity check. The stub | |
| 176 | + | // reads "tool call was aborted; no result recorded." so the | |
| 177 | + | // model can see what happened and decide whether to retry. | |
| 178 | + | // | |
| 179 | + | // Runs once per OpenSession call. No cost on the hot path. | |
| 180 | + | func repairToolUseResultPairs(msgs []provider.Message) []provider.Message { | |
| 181 | + | if len(msgs) == 0 { | |
| 182 | + | return msgs | |
| 183 | + | } | |
| 184 | + | out := make([]provider.Message, 0, len(msgs)+2) | |
| 185 | + | for i, m := range msgs { | |
| 186 | + | out = append(out, m) | |
| 187 | + | if m.Role != provider.RoleAssistant { | |
| 188 | + | continue | |
| 189 | + | } | |
| 190 | + | // Collect tool_use ids in this assistant message. | |
| 191 | + | var ids []string | |
| 192 | + | for _, c := range m.Content { | |
| 193 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |
| 194 | + | ids = append(ids, tc.ID) | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | if len(ids) == 0 { | |
| 198 | + | continue | |
| 199 | + | } | |
| 200 | + | // Look at the next message (if any) and collect tool_result | |
| 201 | + | // CallIDs it covers. | |
| 202 | + | have := map[string]bool{} | |
| 203 | + | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |
| 204 | + | for _, c := range msgs[i+1].Content { | |
| 205 | + | if tr, ok := c.(provider.ToolResultBlock); ok { | |
| 206 | + | have[tr.CallID] = true | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | } | |
| 210 | + | // Build stubs for any missing id. | |
| 211 | + | var stubs []provider.Content | |
| 212 | + | for _, id := range ids { | |
| 213 | + | if have[id] { | |
| 214 | + | continue | |
| 215 | + | } | |
| 216 | + | stubs = append(stubs, provider.ToolResultBlock{ | |
| 217 | + | CallID: id, | |
| 218 | + | Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}}, | |
| 219 | + | IsError: true, | |
| 220 | + | }) | |
| 221 | + | } | |
| 222 | + | if len(stubs) == 0 { | |
| 223 | + | continue | |
| 224 | + | } | |
| 225 | + | // Merge into the next tool-role message if present, | |
| 226 | + | // otherwise insert a synthetic one right after the | |
| 227 | + | // assistant message. Merging keeps the tool-role row | |
| 228 | + | // count stable; inserting handles the common case where | |
| 229 | + | // no tool message was persisted at all. | |
| 230 | + | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |
| 231 | + | msgs[i+1].Content = append(msgs[i+1].Content, stubs...) | |
| 232 | + | // We already appended m to out; the modified next | |
| 233 | + | // message will be appended on the following iteration. | |
| 234 | + | continue | |
| 235 | + | } | |
| 236 | + | out = append(out, provider.Message{ | |
| 237 | + | Role: provider.RoleTool, | |
| 238 | + | Content: stubs, | |
| 239 | + | Time: m.Time, | |
| 240 | + | }) | |
| 241 | + | } | |
| 242 | + | return out | |
| 243 | + | } | |
| 244 | + | ||
| 154 | 245 | // LatestSession returns the most recent session file for cwd, or "". | |
| 155 | 246 | func LatestSession(root, cwd string) string { | |
| 156 | 247 | paths := ListSessions(root, cwd) | |
| @@ -143,6 +143,7 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||||
| 143 | if err := sc.Err(); err != nil { | 143 | if err := sc.Err(); err != nil { | ||
| 144 | return nil, nil, err | 144 | return nil, nil, err | ||
| 145 | } | 145 | } | ||
| 146 | + | messages = repairToolUseResultPairs(messages) | |||
| 146 | out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) | 147 | out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) | ||
| 147 | if err != nil { | 148 | if err != nil { | ||
| 148 | return nil, nil, err | 149 | return nil, nil, err | ||
| @@ -151,6 +152,96 @@ func OpenSession(path string) (*Session, []provider.Message, error) { | |||||
| 151 | return s, messages, nil | 152 | return s, messages, nil | ||
| 152 | } | 153 | } | ||
| 153 | 154 | ||||
| 155 | + | // repairToolUseResultPairs walks a restored transcript and | |||
| 156 | + | // synthesises stub tool_result blocks for any assistant | |||
| 157 | + | // tool_use blocks that aren't paired with a matching result in | |||
| 158 | + | // the next message. Anthropic (and OpenAI via the responses API) | |||
| 159 | + | // reject any request whose transcript leaves a tool_use without | |||
| 160 | + | // its matching tool_result immediately after, with errors like: | |||
| 161 | + | // | |||
| 162 | + | // messages.8: `tool_use` ids were found without `tool_result` | |||
| 163 | + | // blocks immediately after | |||
| 164 | + | // | |||
| 165 | + | // Corruption gets into the transcript two ways we know of: | |||
| 166 | + | // | |||
| 167 | + | // - Older zot builds that persisted the assistant tool_use row | |||
| 168 | + | // before the tool_result row, then crashed between the two. | |||
| 169 | + | // - Abort paths in older builds that didn't drop the mid-turn | |||
| 170 | + | // assistant message cleanly. | |||
| 171 | + | // | |||
| 172 | + | // Rather than change runtime semantics (which would risk hiding a | |||
| 173 | + | // real bug), we scrub on load: any unmatched tool_use gets a stub | |||
| 174 | + | // tool_result injected as a RoleTool message so the next | |||
| 175 | + | // outbound request passes the provider's validity check. The stub | |||
| 176 | + | // reads "tool call was aborted; no result recorded." so the | |||
| 177 | + | // model can see what happened and decide whether to retry. | |||
| 178 | + | // | |||
| 179 | + | // Runs once per OpenSession call. No cost on the hot path. | |||
| 180 | + | func repairToolUseResultPairs(msgs []provider.Message) []provider.Message { | |||
| 181 | + | if len(msgs) == 0 { | |||
| 182 | + | return msgs | |||
| 183 | + | } | |||
| 184 | + | out := make([]provider.Message, 0, len(msgs)+2) | |||
| 185 | + | for i, m := range msgs { | |||
| 186 | + | out = append(out, m) | |||
| 187 | + | if m.Role != provider.RoleAssistant { | |||
| 188 | + | continue | |||
| 189 | + | } | |||
| 190 | + | // Collect tool_use ids in this assistant message. | |||
| 191 | + | var ids []string | |||
| 192 | + | for _, c := range m.Content { | |||
| 193 | + | if tc, ok := c.(provider.ToolCallBlock); ok { | |||
| 194 | + | ids = append(ids, tc.ID) | |||
| 195 | + | } | |||
| 196 | + | } | |||
| 197 | + | if len(ids) == 0 { | |||
| 198 | + | continue | |||
| 199 | + | } | |||
| 200 | + | // Look at the next message (if any) and collect tool_result | |||
| 201 | + | // CallIDs it covers. | |||
| 202 | + | have := map[string]bool{} | |||
| 203 | + | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |||
| 204 | + | for _, c := range msgs[i+1].Content { | |||
| 205 | + | if tr, ok := c.(provider.ToolResultBlock); ok { | |||
| 206 | + | have[tr.CallID] = true | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | } | |||
| 210 | + | // Build stubs for any missing id. | |||
| 211 | + | var stubs []provider.Content | |||
| 212 | + | for _, id := range ids { | |||
| 213 | + | if have[id] { | |||
| 214 | + | continue | |||
| 215 | + | } | |||
| 216 | + | stubs = append(stubs, provider.ToolResultBlock{ | |||
| 217 | + | CallID: id, | |||
| 218 | + | Content: []provider.Content{provider.TextBlock{Text: "tool call was aborted; no result recorded."}}, | |||
| 219 | + | IsError: true, | |||
| 220 | + | }) | |||
| 221 | + | } | |||
| 222 | + | if len(stubs) == 0 { | |||
| 223 | + | continue | |||
| 224 | + | } | |||
| 225 | + | // Merge into the next tool-role message if present, | |||
| 226 | + | // otherwise insert a synthetic one right after the | |||
| 227 | + | // assistant message. Merging keeps the tool-role row | |||
| 228 | + | // count stable; inserting handles the common case where | |||
| 229 | + | // no tool message was persisted at all. | |||
| 230 | + | if i+1 < len(msgs) && msgs[i+1].Role == provider.RoleTool { | |||
| 231 | + | msgs[i+1].Content = append(msgs[i+1].Content, stubs...) | |||
| 232 | + | // We already appended m to out; the modified next | |||
| 233 | + | // message will be appended on the following iteration. | |||
| 234 | + | continue | |||
| 235 | + | } | |||
| 236 | + | out = append(out, provider.Message{ | |||
| 237 | + | Role: provider.RoleTool, | |||
| 238 | + | Content: stubs, | |||
| 239 | + | Time: m.Time, | |||
| 240 | + | }) | |||
| 241 | + | } | |||
| 242 | + | return out | |||
| 243 | + | } | |||
| 244 | + | ||||
| 154 | // LatestSession returns the most recent session file for cwd, or "". | 245 | // LatestSession returns the most recent session file for cwd, or "". | ||
| 155 | func LatestSession(root, cwd string) string { | 246 | func LatestSession(root, cwd string) string { | ||
| 156 | paths := ListSessions(root, cwd) | 247 | paths := ListSessions(root, cwd) | ||
A internal/core/session_repair_test.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package core | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/patriceckhart/zot/internal/provider" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // TestRepairToolUseResultPairsAppendsStub covers the most common | |
| 10 | + | // corruption shape: assistant tool_use row on disk, no | |
| 11 | + | // corresponding tool_result row after it. The repair must inject | |
| 12 | + | // a stub tool_result so the next request passes the provider's | |
| 13 | + | // validity check. | |
| 14 | + | func TestRepairToolUseResultPairsAppendsStub(t *testing.T) { | |
| 15 | + | msgs := []provider.Message{ | |
| 16 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |
| 17 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 18 | + | provider.ToolCallBlock{ID: "t1", Name: "read", Arguments: []byte(`{"path":"/tmp/a"}`)}, | |
| 19 | + | }}, | |
| 20 | + | } | |
| 21 | + | out := repairToolUseResultPairs(msgs) | |
| 22 | + | if len(out) != 3 { | |
| 23 | + | t.Fatalf("expected 3 messages after repair, got %d", len(out)) | |
| 24 | + | } | |
| 25 | + | if out[2].Role != provider.RoleTool { | |
| 26 | + | t.Fatalf("expected tool-role message at index 2, got %v", out[2].Role) | |
| 27 | + | } | |
| 28 | + | tr, ok := out[2].Content[0].(provider.ToolResultBlock) | |
| 29 | + | if !ok { | |
| 30 | + | t.Fatalf("expected ToolResultBlock, got %T", out[2].Content[0]) | |
| 31 | + | } | |
| 32 | + | if tr.CallID != "t1" { | |
| 33 | + | t.Errorf("CallID: want t1, got %q", tr.CallID) | |
| 34 | + | } | |
| 35 | + | if !tr.IsError { | |
| 36 | + | t.Errorf("expected IsError=true on stub") | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // TestRepairToolUseResultPairsMergesIntoExistingToolMessage covers | |
| 41 | + | // the case where a tool-role message exists but is missing one of | |
| 42 | + | // the call ids. The stub must be merged into it (no new message) | |
| 43 | + | // so the ordering stays tool_use, tool_result, tool_use, tool_result. | |
| 44 | + | func TestRepairToolUseResultPairsMergesIntoExistingToolMessage(t *testing.T) { | |
| 45 | + | msgs := []provider.Message{ | |
| 46 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |
| 47 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 48 | + | provider.ToolCallBlock{ID: "t1", Name: "read"}, | |
| 49 | + | provider.ToolCallBlock{ID: "t2", Name: "bash"}, | |
| 50 | + | }}, | |
| 51 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 52 | + | provider.ToolResultBlock{CallID: "t1", Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, | |
| 53 | + | }}, | |
| 54 | + | } | |
| 55 | + | out := repairToolUseResultPairs(msgs) | |
| 56 | + | if len(out) != 3 { | |
| 57 | + | t.Fatalf("expected 3 messages (merged), got %d", len(out)) | |
| 58 | + | } | |
| 59 | + | if out[2].Role != provider.RoleTool { | |
| 60 | + | t.Fatalf("expected tool-role at index 2") | |
| 61 | + | } | |
| 62 | + | if len(out[2].Content) != 2 { | |
| 63 | + | t.Fatalf("expected 2 tool_result blocks after merge, got %d", len(out[2].Content)) | |
| 64 | + | } | |
| 65 | + | // second block must be the stub for t2 | |
| 66 | + | tr, ok := out[2].Content[1].(provider.ToolResultBlock) | |
| 67 | + | if !ok { | |
| 68 | + | t.Fatalf("expected ToolResultBlock at [1], got %T", out[2].Content[1]) | |
| 69 | + | } | |
| 70 | + | if tr.CallID != "t2" { | |
| 71 | + | t.Errorf("merged stub CallID: want t2, got %q", tr.CallID) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // TestRepairToolUseResultPairsLeavesValidTranscriptsAlone verifies | |
| 76 | + | // the repair is a no-op when everything is already paired. | |
| 77 | + | func TestRepairToolUseResultPairsLeavesValidTranscriptsAlone(t *testing.T) { | |
| 78 | + | msgs := []provider.Message{ | |
| 79 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |
| 80 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |
| 81 | + | provider.ToolCallBlock{ID: "t1", Name: "read"}, | |
| 82 | + | }}, | |
| 83 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |
| 84 | + | provider.ToolResultBlock{CallID: "t1", Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, | |
| 85 | + | }}, | |
| 86 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}, | |
| 87 | + | } | |
| 88 | + | out := repairToolUseResultPairs(msgs) | |
| 89 | + | if len(out) != len(msgs) { | |
| 90 | + | t.Fatalf("no-op expected, len %d -> %d", len(msgs), len(out)) | |
| 91 | + | } | |
| 92 | + | for i := range msgs { | |
| 93 | + | if msgs[i].Role != out[i].Role { | |
| 94 | + | t.Errorf("msg %d role changed", i) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | // TestRepairToolUseResultPairsEmpty handles the zero-value case. | |
| 100 | + | func TestRepairToolUseResultPairsEmpty(t *testing.T) { | |
| 101 | + | if out := repairToolUseResultPairs(nil); len(out) != 0 { | |
| 102 | + | t.Fatalf("expected empty, got %d", len(out)) | |
| 103 | + | } | |
| 104 | + | } | |
| @@ -0,0 +1,104 @@ | |||||
| 1 | + | package core | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/patriceckhart/zot/internal/provider" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // TestRepairToolUseResultPairsAppendsStub covers the most common | |||
| 10 | + | // corruption shape: assistant tool_use row on disk, no | |||
| 11 | + | // corresponding tool_result row after it. The repair must inject | |||
| 12 | + | // a stub tool_result so the next request passes the provider's | |||
| 13 | + | // validity check. | |||
| 14 | + | func TestRepairToolUseResultPairsAppendsStub(t *testing.T) { | |||
| 15 | + | msgs := []provider.Message{ | |||
| 16 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |||
| 17 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 18 | + | provider.ToolCallBlock{ID: "t1", Name: "read", Arguments: []byte(`{"path":"/tmp/a"}`)}, | |||
| 19 | + | }}, | |||
| 20 | + | } | |||
| 21 | + | out := repairToolUseResultPairs(msgs) | |||
| 22 | + | if len(out) != 3 { | |||
| 23 | + | t.Fatalf("expected 3 messages after repair, got %d", len(out)) | |||
| 24 | + | } | |||
| 25 | + | if out[2].Role != provider.RoleTool { | |||
| 26 | + | t.Fatalf("expected tool-role message at index 2, got %v", out[2].Role) | |||
| 27 | + | } | |||
| 28 | + | tr, ok := out[2].Content[0].(provider.ToolResultBlock) | |||
| 29 | + | if !ok { | |||
| 30 | + | t.Fatalf("expected ToolResultBlock, got %T", out[2].Content[0]) | |||
| 31 | + | } | |||
| 32 | + | if tr.CallID != "t1" { | |||
| 33 | + | t.Errorf("CallID: want t1, got %q", tr.CallID) | |||
| 34 | + | } | |||
| 35 | + | if !tr.IsError { | |||
| 36 | + | t.Errorf("expected IsError=true on stub") | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // TestRepairToolUseResultPairsMergesIntoExistingToolMessage covers | |||
| 41 | + | // the case where a tool-role message exists but is missing one of | |||
| 42 | + | // the call ids. The stub must be merged into it (no new message) | |||
| 43 | + | // so the ordering stays tool_use, tool_result, tool_use, tool_result. | |||
| 44 | + | func TestRepairToolUseResultPairsMergesIntoExistingToolMessage(t *testing.T) { | |||
| 45 | + | msgs := []provider.Message{ | |||
| 46 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |||
| 47 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 48 | + | provider.ToolCallBlock{ID: "t1", Name: "read"}, | |||
| 49 | + | provider.ToolCallBlock{ID: "t2", Name: "bash"}, | |||
| 50 | + | }}, | |||
| 51 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 52 | + | provider.ToolResultBlock{CallID: "t1", Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, | |||
| 53 | + | }}, | |||
| 54 | + | } | |||
| 55 | + | out := repairToolUseResultPairs(msgs) | |||
| 56 | + | if len(out) != 3 { | |||
| 57 | + | t.Fatalf("expected 3 messages (merged), got %d", len(out)) | |||
| 58 | + | } | |||
| 59 | + | if out[2].Role != provider.RoleTool { | |||
| 60 | + | t.Fatalf("expected tool-role at index 2") | |||
| 61 | + | } | |||
| 62 | + | if len(out[2].Content) != 2 { | |||
| 63 | + | t.Fatalf("expected 2 tool_result blocks after merge, got %d", len(out[2].Content)) | |||
| 64 | + | } | |||
| 65 | + | // second block must be the stub for t2 | |||
| 66 | + | tr, ok := out[2].Content[1].(provider.ToolResultBlock) | |||
| 67 | + | if !ok { | |||
| 68 | + | t.Fatalf("expected ToolResultBlock at [1], got %T", out[2].Content[1]) | |||
| 69 | + | } | |||
| 70 | + | if tr.CallID != "t2" { | |||
| 71 | + | t.Errorf("merged stub CallID: want t2, got %q", tr.CallID) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // TestRepairToolUseResultPairsLeavesValidTranscriptsAlone verifies | |||
| 76 | + | // the repair is a no-op when everything is already paired. | |||
| 77 | + | func TestRepairToolUseResultPairsLeavesValidTranscriptsAlone(t *testing.T) { | |||
| 78 | + | msgs := []provider.Message{ | |||
| 79 | + | {Role: provider.RoleUser, Content: []provider.Content{provider.TextBlock{Text: "hi"}}}, | |||
| 80 | + | {Role: provider.RoleAssistant, Content: []provider.Content{ | |||
| 81 | + | provider.ToolCallBlock{ID: "t1", Name: "read"}, | |||
| 82 | + | }}, | |||
| 83 | + | {Role: provider.RoleTool, Content: []provider.Content{ | |||
| 84 | + | provider.ToolResultBlock{CallID: "t1", Content: []provider.Content{provider.TextBlock{Text: "ok"}}}, | |||
| 85 | + | }}, | |||
| 86 | + | {Role: provider.RoleAssistant, Content: []provider.Content{provider.TextBlock{Text: "done"}}}, | |||
| 87 | + | } | |||
| 88 | + | out := repairToolUseResultPairs(msgs) | |||
| 89 | + | if len(out) != len(msgs) { | |||
| 90 | + | t.Fatalf("no-op expected, len %d -> %d", len(msgs), len(out)) | |||
| 91 | + | } | |||
| 92 | + | for i := range msgs { | |||
| 93 | + | if msgs[i].Role != out[i].Role { | |||
| 94 | + | t.Errorf("msg %d role changed", i) | |||
| 95 | + | } | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | // TestRepairToolUseResultPairsEmpty handles the zero-value case. | |||
| 100 | + | func TestRepairToolUseResultPairsEmpty(t *testing.T) { | |||
| 101 | + | if out := repairToolUseResultPairs(nil); len(out) != 0 { | |||
| 102 | + | t.Fatalf("expected empty, got %d", len(out)) | |||
| 103 | + | } | |||
| 104 | + | } | |||