diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 3a4f6c25d..1dafe1dc9 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -2298,6 +2298,23 @@ export interface SyntheticToolResultDetails { upstreamError?: string; } +/** + * Narrow an {@link AgentMessage} to a synthetic {@link ToolResultMessage} — + * a tool_result emitted for a tool call the assistant never invoked (see + * {@link SyntheticToolResultDetails}). Consumers use this to look past the + * placeholder pairing back to the assistant turn that produced it, e.g. + * `AgentSession.retry()` walking back over the synthetic results a + * stalled/aborted mid-tool-call turn leaves behind. + */ +export function isSyntheticToolResultMessage( + message: AgentMessage | undefined, +): message is ToolResultMessage { + return ( + message?.role === "toolResult" && + (message.details as SyntheticToolResultDetails | undefined)?.__synthetic === true + ); +} + function syntheticDetailsFor( reason: "aborted" | "error" | "skipped" | "length", errorMessage: string | undefined, diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index c40d5b070..c5fa9fa4c 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -147,6 +147,7 @@ - Fixed dynamic model discovery refreshes dropping provider-level compatibility overrides from `models.yml` ([#6041](https://github.com/can1357/oh-my-pi/issues/6041)). - Fixed a startup crash that locked users out of the app when `prewalk.enabled` was set but the prewalk hand-off target (default `@smol`) had no configured API key; prewalk now stays unarmed with a warning instead of aborting startup ([#6064](https://github.com/can1357/oh-my-pi/issues/6064)). - Fixed in-progress aborts awaiting `session_stop` extension handlers whose results would be discarded ([#6134](https://github.com/can1357/oh-my-pi/issues/6134)). +- Fixed `/retry` reporting "Nothing to retry" after a stream stalled or aborted mid-tool-call, where a synthetic tool result appended for the un-run tool call shadowed the failed assistant turn ([#6056](https://github.com/can1357/oh-my-pi/issues/6056)). ## [17.0.5] - 2026-07-18 diff --git a/packages/coding-agent/src/session/agent-session.ts b/packages/coding-agent/src/session/agent-session.ts index acd2451e8..a7429799f 100644 --- a/packages/coding-agent/src/session/agent-session.ts +++ b/packages/coding-agent/src/session/agent-session.ts @@ -37,6 +37,7 @@ import { type CompactionSummaryMessage, countTokens, createToolScopedAbortReason, + isSyntheticToolResultMessage, resolveTelemetry, type StreamFn, TERMINAL_TOOL_RESULT_ABORT_REASON, @@ -15758,20 +15759,40 @@ export class AgentSession { /** * Manually retry the last failed assistant turn. * Removes the error message from agent state and re-attempts with a fresh retry budget. + * + * A stream that stalls or aborts mid-tool-call ends the turn with + * `stopReason: "error" | "aborted"` and then appends one synthetic + * {@link isSyntheticToolResultMessage tool_result} per emitted tool call to + * preserve the provider's tool_use/tool_result pairing (see + * `createAbortedToolResult` in `agent-loop.ts`). Those placeholders trail the + * failed assistant turn, so the retry lookback walks back over them before + * checking the assistant message; it strips both the placeholders and the + * failed turn before re-attempting. + * * @returns true if retry was initiated, false if no failed turn to retry or agent is busy */ async retry(): Promise { if (this.isStreaming || this.isCompacting || this.isRetrying) return false; const messages = this.agent.state.messages; - const lastMsg = messages[messages.length - 1]; + + // Walk back past trailing synthetic tool_result placeholders emitted for + // tool calls that never ran because the turn stalled/aborted mid-tool-call. + // They shadow the failed assistant turn from the single-message lookback. + let turnEnd = messages.length; + while (turnEnd > 0 && isSyntheticToolResultMessage(messages[turnEnd - 1])) { + turnEnd--; + } + + const lastMsg = messages[turnEnd - 1]; if (lastMsg?.role !== "assistant") return false; const assistantMsg = lastMsg as AssistantMessage; if (assistantMsg.stopReason !== "error" && assistantMsg.stopReason !== "aborted") return false; - // Remove the failed/aborted assistant message (same as auto-retry does before re-attempting) - this.agent.replaceMessages(messages.slice(0, -1)); + // Remove the failed/aborted assistant message plus its synthetic tool + // results (same as auto-retry does before re-attempting). + this.agent.replaceMessages(messages.slice(0, turnEnd - 1)); // Reset retry budget for a fresh attempt this.#retryAttempt = 0; diff --git a/packages/coding-agent/test/agent-session-manual-retry.test.ts b/packages/coding-agent/test/agent-session-manual-retry.test.ts index d90820262..5bc94f805 100644 --- a/packages/coding-agent/test/agent-session-manual-retry.test.ts +++ b/packages/coding-agent/test/agent-session-manual-retry.test.ts @@ -115,4 +115,62 @@ describe("AgentSession manual retry", () => { expect(mock.calls.length).toBe(1); expect(lastAgentMessage(session).content).toContainEqual({ type: "text", text: "already done" }); }); + + it("retries past synthetic tool results left by a mid-tool-call stream stall", async () => { + const model = getBundledModel("anthropic", "claude-sonnet-4-5"); + if (!model) { + throw new Error("Expected bundled Anthropic test model to exist"); + } + + // First turn stalls mid-tool-call: the assistant emits a `write` tool call + // but the stream ends with an error before it runs, so `stopReason: "error"`. + // The agent loop then appends a synthetic tool_result for the un-run call, + // which trails the failed assistant turn in agent state. + const mock = createMockModel({ + responses: [ + { + content: [{ type: "toolCall", name: "write", arguments: { path: "plan.md", content: "x" } }], + stopReason: "error", + errorMessage: "OpenAI completions stream stalled while waiting for the next event", + }, + { content: ["recovered after stalled tool call"], stopReason: "stop" }, + ], + }); + const agent = new Agent({ + getApiKey: model => `${model.provider}-test-key`, + initialState: { + model, + systemPrompt: ["Test"], + tools: [], + messages: [], + }, + streamFn: mock.stream, + }); + session = new AgentSession({ + agent, + sessionManager: SessionManager.inMemory(), + settings: Settings.isolated({ "compaction.enabled": false, "retry.enabled": false }), + modelRegistry: new ModelRegistry(authStorage), + }); + session.subscribe(() => {}); + + await session.prompt("write the plan"); + await session.waitForIdle(); + + // The failed assistant turn is shadowed by a trailing synthetic tool_result. + const messages = session.agent.state.messages; + expect(messages.at(-1)?.role).toBe("toolResult"); + const failedAssistant = messages.findLast(m => m.role === "assistant") as AssistantMessage; + expect(failedAssistant.stopReason).toBe("error"); + + await expect(session.retry()).resolves.toBe(true); + await session.waitForIdle(); + + expect(mock.calls.length).toBe(2); + expect(lastAgentMessage(session).stopReason).toBe("stop"); + expect(lastAgentMessage(session).content).toContainEqual({ + type: "text", + text: "recovered after stalled tool call", + }); + }); });