detached c16e929

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) {
143143 if err := sc.Err(); err != nil {
144144 return nil, nil, err
145145 }
146+ messages = repairToolUseResultPairs(messages)
146147 out, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644)
147148 if err != nil {
148149 return nil, nil, err
@@ -151,6 +152,96 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
151152 return s, messages, nil
152153 }
153154
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+
154245 // LatestSession returns the most recent session file for cwd, or "".
155246 func LatestSession(root, cwd string) string {
156247 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, err144 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, err149 return nil, nil, err
@@ -151,6 +152,96 @@ func OpenSession(path string) (*Session, []provider.Message, error) {
151 return s, messages, nil152 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+}

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