fix: inject synthetic tool_result for orphaned tool_use on session resume (#2622)

Kim Yann , Christian Rocha , and Andrey Nering created

When a session is interrupted mid-tool-call (Ctrl-C, network timeout),
the assistant message contains a tool_use block but no corresponding
tool_result is ever recorded. On session resume, the Anthropic API
rejects the conversation with invalid_request_error, permanently
locking the session.

The existing filterOrphanedToolResults handles the inverse case
(tool_result without a matching tool_use). This commit adds the
symmetric fix: during preparePrompt, detect tool_use IDs that have
no matching tool_result and inject a synthetic error tool_result so
the API accepts the history and the session can continue.

Co-authored-by: Christian Rocha <christian@rocha.is>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

Change summary

internal/agent/agent.go      |  55 +++++++++++++-
internal/agent/agent_test.go | 141 ++++++++++++++++++++++++++++++++++++++
2 files changed, 192 insertions(+), 4 deletions(-)

Detailed changes

internal/agent/agent.go 🔗

@@ -772,13 +772,22 @@ If not, please feel free to ignore. Again do not mention this message to the use
 			),
 		))
 	}
-	// Collect all tool call IDs present in assistant messages.
+	// Collect all tool call IDs present in assistant messages and all tool
+	// result IDs present in tool messages. This lets us detect both orphaned
+	// tool results (result without a call) and orphaned tool calls (call
+	// without a result).
 	knownToolCallIDs := make(map[string]struct{})
+	knownToolResultIDs := make(map[string]struct{})
 	for _, m := range msgs {
-		if m.Role == message.Assistant {
+		switch m.Role {
+		case message.Assistant:
 			for _, tc := range m.ToolCalls() {
 				knownToolCallIDs[tc.ID] = struct{}{}
 			}
+		case message.Tool:
+			for _, tr := range m.ToolResults() {
+				knownToolResultIDs[tr.ToolCallID] = struct{}{}
+			}
 		}
 	}
 
@@ -786,8 +795,7 @@ If not, please feel free to ignore. Again do not mention this message to the use
 		if len(m.Parts) == 0 {
 			continue
 		}
-		// Assistant message without content or tool calls (cancelled before it
-		// returned anything).
+		// Assistant message without content or tool calls (cancelled before it returned anything).
 		if m.Role == message.Assistant && len(m.ToolCalls()) == 0 && m.Content().Text == "" && m.ReasoningContent().String() == "" {
 			continue
 		}
@@ -798,6 +806,12 @@ If not, please feel free to ignore. Again do not mention this message to the use
 			continue
 		}
 		history = append(history, m.ToAIMessage()...)
+
+		if m.Role == message.Assistant {
+			if msg, ok := syntheticToolResultsForOrphanedCalls(m, knownToolResultIDs); ok {
+				history = append(history, msg)
+			}
+		}
 	}
 
 	var files []fantasy.FilePart
@@ -848,6 +862,39 @@ func filterOrphanedToolResults(m message.Message, knownToolCallIDs map[string]st
 	return msg, true
 }
 
+// syntheticToolResultsForOrphanedCalls returns a tool message containing
+// synthetic tool results for any tool calls in the assistant message that
+// have no matching result in knownToolResultIDs. LLM APIs require every
+// tool_use to be immediately followed by a tool_result; an interrupted
+// session can leave orphaned tool_use blocks that permanently lock the
+// conversation. Returns the message and true if any synthetic results were
+// produced.
+func syntheticToolResultsForOrphanedCalls(m message.Message, knownToolResultIDs map[string]struct{}) (fantasy.Message, bool) {
+	var syntheticParts []fantasy.MessagePart
+	for _, tc := range m.ToolCalls() {
+		if _, hasResult := knownToolResultIDs[tc.ID]; hasResult {
+			continue
+		}
+		slog.Warn("Injecting synthetic tool result for orphaned tool call",
+			"tool_call_id", tc.ID,
+			"tool_name", tc.Name,
+		)
+		syntheticParts = append(syntheticParts, fantasy.ToolResultPart{
+			ToolCallID: tc.ID,
+			Output: fantasy.ToolResultOutputContentError{
+				Error: errors.New("tool call was interrupted and did not produce a result, you may retry this call if the result is still needed"),
+			},
+		})
+	}
+	if len(syntheticParts) == 0 {
+		return fantasy.Message{}, false
+	}
+	return fantasy.Message{
+		Role:    fantasy.MessageRoleTool,
+		Content: syntheticParts,
+	}, true
+}
+
 func (a *sessionAgent) getSessionMessages(ctx context.Context, session session.Session) ([]message.Message, error) {
 	msgs, err := a.messages.List(ctx, session.ID)
 	if err != nil {

internal/agent/agent_test.go 🔗

@@ -651,3 +651,144 @@ func BenchmarkBuildSummaryPrompt(b *testing.B) {
 		})
 	}
 }
+
+func TestPreparePrompt_OrphanedToolUse(t *testing.T) {
+	t.Parallel()
+	env := testEnv(t)
+	sa := testSessionAgent(env, nil, nil, "test prompt")
+	agent := sa.(*sessionAgent)
+
+	ctx := t.Context()
+	sess, err := env.sessions.Create(ctx, "test")
+	require.NoError(t, err)
+
+	// Create a user message.
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.User,
+		Parts: []message.ContentPart{
+			message.TextContent{Text: "hello"},
+		},
+	})
+	require.NoError(t, err)
+
+	// Create an assistant message with a tool call but no tool result —
+	// this simulates a cancelled/interrupted agent tool call.
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.Assistant,
+		Parts: []message.ContentPart{
+			message.TextContent{Text: "let me check"},
+			message.ToolCall{
+				ID:       "call_orphaned_1",
+				Name:     "agent",
+				Input:    `{"prompt":"do something"}`,
+				Finished: true,
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	// Create the next user message (the one that interrupted the tool call).
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.User,
+		Parts: []message.ContentPart{
+			message.TextContent{Text: "Fix #2"},
+		},
+	})
+	require.NoError(t, err)
+
+	msgs, err := env.messages.List(ctx, sess.ID)
+	require.NoError(t, err)
+
+	history, _ := agent.preparePrompt(msgs)
+
+	// The history must contain a synthetic tool result for the orphaned call.
+	found := false
+	for _, msg := range history {
+		if msg.Role != fantasy.MessageRoleTool {
+			continue
+		}
+		for _, part := range msg.Content {
+			if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
+				if tr.ToolCallID == "call_orphaned_1" {
+					found = true
+					_, isError := tr.Output.(fantasy.ToolResultOutputContentError)
+					require.True(t, isError, "orphaned tool result should be an error")
+				}
+			}
+		}
+	}
+	require.True(t, found, "expected synthetic tool result for orphaned tool call")
+}
+
+func TestPreparePrompt_OrphanedToolUseMixed(t *testing.T) {
+	t.Parallel()
+	env := testEnv(t)
+	sa := testSessionAgent(env, nil, nil, "test prompt")
+	agent := sa.(*sessionAgent)
+
+	ctx := t.Context()
+	sess, err := env.sessions.Create(ctx, "test")
+	require.NoError(t, err)
+
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.User,
+		Parts: []message.ContentPart{
+			message.TextContent{Text: "hello"},
+		},
+	})
+	require.NoError(t, err)
+
+	// Assistant with 2 tool calls: one has a result, one is orphaned.
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.Assistant,
+		Parts: []message.ContentPart{
+			message.ToolCall{
+				ID:       "call_ok",
+				Name:     "view",
+				Input:    `{"path":"/foo"}`,
+				Finished: true,
+			},
+			message.ToolCall{
+				ID:       "call_orphaned",
+				Name:     "agent",
+				Input:    `{"prompt":"search"}`,
+				Finished: true,
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	// Only one tool result — for call_ok.
+	_, err = env.messages.Create(ctx, sess.ID, message.CreateMessageParams{
+		Role: message.Tool,
+		Parts: []message.ContentPart{
+			message.ToolResult{
+				ToolCallID: "call_ok",
+				Name:       "view",
+				Content:    "file contents",
+			},
+		},
+	})
+	require.NoError(t, err)
+
+	msgs, err := env.messages.List(ctx, sess.ID)
+	require.NoError(t, err)
+
+	history, _ := agent.preparePrompt(msgs)
+
+	// Should have a synthetic result only for the orphaned call.
+	var syntheticCount int
+	for _, msg := range history {
+		if msg.Role != fantasy.MessageRoleTool {
+			continue
+		}
+		for _, part := range msg.Content {
+			if tr, ok := fantasy.AsMessagePart[fantasy.ToolResultPart](part); ok {
+				if tr.ToolCallID == "call_orphaned" {
+					syntheticCount++
+				}
+			}
+		}
+	}
+	require.Equal(t, 1, syntheticCount, "expected exactly one synthetic result for the orphaned call")
+}