From 3b19c2af61dd8202b89981c85f9802fc3b7ef348 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 14:53:54 +0200 Subject: [PATCH 1/4] fix(agent): retry empty final assistant turns instead of silently completing (ENG-5795) Providers occasionally end a stream with a normal stop reason but no usable output (no text, no tool calls; sometimes thinking-only). The agent loop treated any no-tool-call turn as completion, silently abandoning the task (-p printed nothing and exited 0). - agent: an empty final turn (no tool calls, no non-thinking content) is silently resent up to 3 attempts; empty attempts are dropped from the request context and never emitted as message_end, so they do not pollute the transcript. After the third empty response the turn ends as a normal turn error (print mode exits non-zero via the existing error path). - coding-agent: an RLM child whose final turn ended in a graceful error and that never replied now surfaces to the parent as rlm_child_failure with the error text instead of a bare completed-without-reply notice. --- packages/agent/.changes/empty-turn-retry.md | 1 + packages/agent/src/agent-loop.ts | 44 ++++- packages/agent/test/agent-loop.test.ts | 183 ++++++++++++++++++ .../coding-agent/.changes/empty-turn-retry.md | 1 + .../coding-agent/src/core/agent-session.ts | 31 ++- .../test/agent-session-recursion.test.ts | 50 +++++ 6 files changed, 298 insertions(+), 12 deletions(-) create mode 100644 packages/agent/.changes/empty-turn-retry.md create mode 100644 packages/coding-agent/.changes/empty-turn-retry.md diff --git a/packages/agent/.changes/empty-turn-retry.md b/packages/agent/.changes/empty-turn-retry.md new file mode 100644 index 0000000000..c3d0d9eeec --- /dev/null +++ b/packages/agent/.changes/empty-turn-retry.md @@ -0,0 +1 @@ +- Fixed the agent loop treating an empty final assistant turn (no output content and no tool calls, e.g. a provider ending the stream thinking-only with a normal stop reason) as successful completion: the request is silently resent up to 3 attempts without the empty turns polluting the retry context or transcript, and a turn error is surfaced after the third consecutive empty response. diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 31b3a15ccc..39090f3760 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -448,12 +448,53 @@ async function runLoop( await emit({ type: "agent_end", messages: newMessages }); } +const MAX_EMPTY_TURN_ATTEMPTS = 3; + +/** + * A final turn with no tool calls and no non-thinking content. Providers occasionally + * end a stream like this with a normal stop reason; treating it as completion would + * silently abandon the task, so it is retried instead. + */ +function isEmptyAssistantTurn(message: AssistantMessage): boolean { + if (message.stopReason === "error" || message.stopReason === "aborted") { + return false; + } + return !message.content.some( + (part) => part.type === "toolCall" || (part.type === "text" && part.text.trim().length > 0), + ); +} + async function streamAssistantResponse( context: AgentContext, config: AgentLoopConfig, signal: AbortSignal | undefined, emit: AgentEventSink, streamFn?: StreamFn, +): Promise { + for (let attempt = 1; ; attempt++) { + const message = await streamAssistantResponseAttempt(context, config, signal, emit, streamFn); + if (isEmptyAssistantTurn(message)) { + if (attempt < MAX_EMPTY_TURN_ATTEMPTS) { + // Drop the empty attempt so it is neither resent to the provider nor + // finalized as a transcript turn (message_end is what makes it durable). + context.messages.pop(); + continue; + } + message.stopReason = "error"; + message.errorMessage = `Model returned an empty response (no output content or tool calls) ${MAX_EMPTY_TURN_ATTEMPTS} times in a row`; + } + await emit({ type: "message_end", message }); + return message; + } +} + +/** Runs one assistant stream and places the final message in context, without emitting message_end. */ +async function streamAssistantResponseAttempt( + context: AgentContext, + config: AgentLoopConfig, + signal: AbortSignal | undefined, + emit: AgentEventSink, + streamFn?: StreamFn, ): Promise { let partialMessage: AssistantMessage | null = null; let addedPartial = false; @@ -465,7 +506,6 @@ async function streamAssistantResponse( context.messages.push(finalMessage); await emit({ type: "message_start", message: { ...finalMessage } }); } - await emit({ type: "message_end", message: finalMessage }); return finalMessage; }; @@ -559,7 +599,6 @@ async function streamAssistantResponse( if (!addedPartial) { await emit({ type: "message_start", message: { ...finalMessage } }); } - await emit({ type: "message_end", message: finalMessage }); return finalMessage; } } @@ -572,7 +611,6 @@ async function streamAssistantResponse( context.messages.push(finalMessage); await emit({ type: "message_start", message: { ...finalMessage } }); } - await emit({ type: "message_end", message: finalMessage }); return finalMessage; } catch (error) { if (signal?.aborted && isAbortError(error)) { diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index d8167c0c13..8d396d2e5f 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1964,3 +1964,186 @@ describe("agentLoopContinue with AgentMessage", () => { expect(messages[0].role).toBe("assistant"); }); }); + +describe("empty assistant turn retry", () => { + function emptyTurnConfig(): AgentLoopConfig { + return { + model: createModel(), + convertToLlm: identityConverter, + }; + } + + function streamFnReturning(messages: AssistantMessage[]) { + const requests: Message[][] = []; + let call = 0; + const streamFn = ((_model: unknown, llmContext: { messages: Message[] }) => { + requests.push(llmContext.messages.slice()); + const message = messages[Math.min(call, messages.length - 1)]; + call += 1; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + stream.push({ type: "done", reason: "stop", message }); + }); + return stream; + }) as unknown as Parameters[5]; + return { streamFn, requests }; + } + + it("silently retries a thinking-only final turn and keeps the transcript clean", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const emptyMessage = createAssistantMessage([{ type: "thinking", thinking: "pondering..." }]); + const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); + const { streamFn, requests } = streamFnReturning([emptyMessage, goodMessage]); + const events: AgentEvent[] = []; + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + (event) => { + events.push(event); + }, + undefined, + streamFn, + ); + + expect(requests.length).toBe(2); + // The empty attempt must not be resent to the provider. + expect(requests[1].filter((message) => message.role === "assistant")).toEqual([]); + // The empty attempt must not become a transcript turn. + const assistants = messages.filter((message) => message.role === "assistant"); + expect(assistants).toEqual([goodMessage]); + const assistantEnds = events.filter( + (event) => event.type === "message_end" && event.message.role === "assistant", + ); + expect(assistantEnds.length).toBe(1); + }); + + it("retries a whitespace-only text turn", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const emptyMessage = createAssistantMessage([{ type: "text", text: " \n" }]); + const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); + const { streamFn, requests } = streamFnReturning([emptyMessage, goodMessage]); + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + () => {}, + undefined, + streamFn, + ); + + expect(requests.length).toBe(2); + const assistants = messages.filter((message) => message.role === "assistant"); + expect(assistants).toEqual([goodMessage]); + }); + + it("surfaces an error after three consecutive empty turns", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const emptyMessage = createAssistantMessage([{ type: "thinking", thinking: "..." }]); + const { streamFn, requests } = streamFnReturning([emptyMessage]); + const events: AgentEvent[] = []; + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + (event) => { + events.push(event); + }, + undefined, + streamFn, + ); + + expect(requests.length).toBe(3); + const assistants = messages.filter((message) => message.role === "assistant"); + expect(assistants.length).toBe(1); + const finalMessage = assistants[0] as AssistantMessage; + expect(finalMessage.stopReason).toBe("error"); + expect(finalMessage.errorMessage).toMatch(/empty response/i); + expect(events.at(-1)?.type).toBe("agent_end"); + }); + + it("does not retry when non-thinking content exists", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const message = createAssistantMessage([ + { type: "thinking", thinking: "..." }, + { type: "text", text: "partial but visible" }, + ]); + const { streamFn, requests } = streamFnReturning([message]); + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + () => {}, + undefined, + streamFn, + ); + + expect(requests.length).toBe(1); + const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; + expect(assistant.stopReason).toBe("stop"); + }); + + it("does not retry when the turn has tool calls but no text", async () => { + const toolSchema = Type.Object({}); + const tool: AgentTool> = { + name: "work", + label: "Work", + description: "Work", + parameters: toolSchema, + execute: async () => ({ content: [{ type: "text", text: "tool output" }], details: {} }), + }; + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [tool] }; + const toolMessage = createAssistantMessage( + [{ type: "toolCall", id: "tool_1", name: "work", arguments: {} }], + "toolUse", + ); + const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); + const { streamFn, requests } = streamFnReturning([toolMessage, goodMessage]); + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + () => {}, + undefined, + streamFn, + ); + + expect(requests.length).toBe(2); + expect(messages.filter((m) => m.role === "toolResult").length).toBe(1); + const assistants = messages.filter((m) => m.role === "assistant"); + expect(assistants).toEqual([toolMessage, goodMessage]); + }); + + it("does not retry an aborted empty turn", async () => { + const controller = new AbortController(); + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const config = emptyTurnConfig(); + let calls = 0; + const streamFn = (() => { + calls += 1; + const stream = new MockAssistantStream(); + queueMicrotask(() => { + controller.abort(); + }); + return stream; + }) as unknown as Parameters[5]; + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + config, + () => {}, + controller.signal, + streamFn, + ); + + expect(calls).toBe(1); + const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; + expect(assistant.stopReason).toBe("aborted"); + }); +}); diff --git a/packages/coding-agent/.changes/empty-turn-retry.md b/packages/coding-agent/.changes/empty-turn-retry.md new file mode 100644 index 0000000000..5a3bcdbc55 --- /dev/null +++ b/packages/coding-agent/.changes/empty-turn-retry.md @@ -0,0 +1 @@ +- Surfaced an RLM child whose final turn ended in an error (e.g. exhausted empty-response retries) to the parent as a child failure message instead of a bare completed-without-reply notice. diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c0e036cf1d..035738cae1 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -10636,15 +10636,28 @@ export class AgentSession { !run.suppressTerminalNotice && child._parentReplyCount === parentReplyCountBeforeRun ) { - const lastAssistantText = child.getLastAssistantText(); - await deliverTerminalMessageToParent( - createRlmChildTerminalNoticeMessage({ - kind: "completed_without_reply", - childId: run.id, - sessionName, - lastAssistantTextPreview: lastAssistantText ? compactRlmText(lastAssistantText) : undefined, - }), - ); + // A turn that ends with a graceful error message resolves promptAndWait, + // so it must be surfaced here or the parent never learns the task failed. + const lastAssistant = this._findLastAssistantInMessages(child.messages); + if (lastAssistant?.stopReason === "error") { + await deliverTerminalMessageToParent( + createRlmChildFailureMessage({ + childId: run.id, + sessionName, + error: lastAssistant.errorMessage ?? "Assistant turn failed", + }), + ); + } else { + const lastAssistantText = child.getLastAssistantText(); + await deliverTerminalMessageToParent( + createRlmChildTerminalNoticeMessage({ + kind: "completed_without_reply", + childId: run.id, + sessionName, + lastAssistantTextPreview: lastAssistantText ? compactRlmText(lastAssistantText) : undefined, + }), + ); + } } if (!this.registerRlmChildSession(run.id, child) && !run.detachedDeletion) { if (childRuntime && this._subagentRuntimeHost?.releaseRlmSubagentRuntime) { diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index d085d010a2..9093e5546b 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1332,6 +1332,56 @@ describe("AgentSession rlm recursion", () => { }); }); + it("surfaces a child turn that exhausts empty-response retries as a failure to the parent", async () => { + const emptyAssistantMessage = (): AssistantMessage => ({ + role: "assistant", + content: [{ type: "thinking", thinking: "pondering" }], + api: model.api, + provider: model.provider, + model: model.id, + usage: usage(), + stopReason: "stop", + timestamp: Date.now(), + }); + let childStreamCalls = 0; + const root = createSession({ + // The child task prompt streams empty turns; the parent's reaction to the + // failure notice answers normally. + streamFn: (_model, context) => { + if (!userText(context).includes("empty child")) { + return streamAnswer("acknowledged"); + } + childStreamCalls += 1; + const stream = createAssistantMessageEventStream(); + queueMicrotask(() => { + stream.push({ type: "done", reason: "stop", message: emptyAssistantMessage() }); + }); + return stream; + }, + }); + // Session-level auto-retry also retries error turns; disable it so the + // child's turn error surfaces immediately instead of after backoff. + root.settingsManager.setRetryEnabled(false); + + const spawned = await root.runRlmChild("empty child", { name: "empty-worker" }); + await vi.waitFor(() => { + const failures = root.messages.filter( + (message) => message.role === "custom" && message.customType === "rlm_child_failure", + ); + expect(failures).toHaveLength(1); + expect(failures[0]).toMatchObject({ + content: expect.stringContaining(`RLM child empty-worker (${spawned.rlm_child_id}) failed:`), + }); + expect((failures[0] as { content: string }).content).toMatch(/empty response/i); + }); + expect(childStreamCalls).toBe(3); + expect( + root.messages.filter( + (message) => message.role === "custom" && message.customType === "rlm_child_terminal_notice", + ), + ).toHaveLength(0); + }); + it("suppresses a done child's unsettled fallback notice at the cancellation cut", async () => { const root = createSession(); let suppressed = false; From 50706121ffc353a9255ca97a3d4b2ce80b30e0a9 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 15:11:17 +0200 Subject: [PATCH 2/4] fix(agent): exclude context-overflow shapes from empty-turn retry (ENG-5795) Review follow-up: an empty length-stop turn (Xiaomi MiMo overflow shape) and a silent stop-overflow turn (z.ai shape, usage.input past the context window) must pass through untouched so agent_end compaction recovery can see them. The retry now skips stopReason length entirely (truncation is a signal; an identical resend cannot change the outcome) and guards with the existing isContextOverflow detector instead of re-deriving overflow logic. --- packages/agent/src/agent-loop.ts | 9 +++-- packages/agent/test/agent-loop.test.ts | 51 ++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 3 deletions(-) diff --git a/packages/agent/src/agent-loop.ts b/packages/agent/src/agent-loop.ts index 39090f3760..d10b5f4530 100644 --- a/packages/agent/src/agent-loop.ts +++ b/packages/agent/src/agent-loop.ts @@ -8,6 +8,7 @@ import { type AssistantMessageEvent, type Context, EventStream, + isContextOverflow, streamSimple, type ToolResultMessage, validateToolArguments, @@ -453,10 +454,11 @@ const MAX_EMPTY_TURN_ATTEMPTS = 3; /** * A final turn with no tool calls and no non-thinking content. Providers occasionally * end a stream like this with a normal stop reason; treating it as completion would - * silently abandon the task, so it is retried instead. + * silently abandon the task, so it is retried instead. Error, abort, and length turns + * are excluded: they are signals of their own, and an identical resend cannot help. */ function isEmptyAssistantTurn(message: AssistantMessage): boolean { - if (message.stopReason === "error" || message.stopReason === "aborted") { + if (message.stopReason === "error" || message.stopReason === "aborted" || message.stopReason === "length") { return false; } return !message.content.some( @@ -473,7 +475,8 @@ async function streamAssistantResponse( ): Promise { for (let attempt = 1; ; attempt++) { const message = await streamAssistantResponseAttempt(context, config, signal, emit, streamFn); - if (isEmptyAssistantTurn(message)) { + // Overflow turns must pass through untouched so compaction recovery can see them. + if (isEmptyAssistantTurn(message) && !isContextOverflow(message, config.model.contextWindow)) { if (attempt < MAX_EMPTY_TURN_ATTEMPTS) { // Drop the empty attempt so it is neither resent to the provider nor // finalized as a transcript turn (message_end is what makes it durable). diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 8d396d2e5f..46a2d1c2b7 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -2119,6 +2119,57 @@ describe("empty assistant turn retry", () => { expect(assistants).toEqual([toolMessage, goodMessage]); }); + it("does not retry an empty length-stop turn", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const message = createAssistantMessage([{ type: "thinking", thinking: "..." }], "length"); + const { streamFn, requests } = streamFnReturning([message]); + const events: AgentEvent[] = []; + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + (event) => { + events.push(event); + }, + undefined, + streamFn, + ); + + expect(requests.length).toBe(1); + const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; + expect(assistant.stopReason).toBe("length"); + expect(assistant.errorMessage).toBeUndefined(); + expect(events.some((event) => event.type === "message_end" && event.message === assistant)).toBe(true); + }); + + it("does not retry a silent context-overflow turn so compaction recovery can see it", async () => { + const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; + const message = createAssistantMessage([{ type: "text", text: "" }]); + // z.ai-style silent overflow: normal stop, empty content, input past the window. + message.usage.input = createModel().contextWindow + 1; + const { streamFn, requests } = streamFnReturning([message]); + const events: AgentEvent[] = []; + + const messages = await runAgentLoop( + [createUserMessage("Hello")], + context, + emptyTurnConfig(), + (event) => { + events.push(event); + }, + undefined, + streamFn, + ); + + expect(requests.length).toBe(1); + const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; + expect(assistant).toBe(message); + expect(assistant.stopReason).toBe("stop"); + expect(assistant.errorMessage).toBeUndefined(); + expect(events.some((event) => event.type === "message_end" && event.message === assistant)).toBe(true); + }); + it("does not retry an aborted empty turn", async () => { const controller = new AbortController(); const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; From 1be0bc3987d266613d158719f4c3d7d4a857f309 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sat, 29 Aug 2026 21:38:55 +0200 Subject: [PATCH 3/4] test(agent): collapse redundant empty-turn coverage --- packages/agent/test/agent-loop.test.ts | 182 ++++++------------------- 1 file changed, 44 insertions(+), 138 deletions(-) diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index 46a2d1c2b7..d5fe7ba40d 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -1989,13 +1989,10 @@ describe("empty assistant turn retry", () => { return { streamFn, requests }; } - it("silently retries a thinking-only final turn and keeps the transcript clean", async () => { + async function runOnce(message: AssistantMessage) { const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const emptyMessage = createAssistantMessage([{ type: "thinking", thinking: "pondering..." }]); - const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); - const { streamFn, requests } = streamFnReturning([emptyMessage, goodMessage]); + const { streamFn, requests } = streamFnReturning([message]); const events: AgentEvent[] = []; - const messages = await runAgentLoop( [createUserMessage("Hello")], context, @@ -2006,43 +2003,15 @@ describe("empty assistant turn retry", () => { undefined, streamFn, ); + return { requests, events, assistant: messages.find((m) => m.role === "assistant") as AssistantMessage }; + } - expect(requests.length).toBe(2); - // The empty attempt must not be resent to the provider. - expect(requests[1].filter((message) => message.role === "assistant")).toEqual([]); - // The empty attempt must not become a transcript turn. - const assistants = messages.filter((message) => message.role === "assistant"); - expect(assistants).toEqual([goodMessage]); - const assistantEnds = events.filter( - (event) => event.type === "message_end" && event.message.role === "assistant", - ); - expect(assistantEnds.length).toBe(1); - }); - - it("retries a whitespace-only text turn", async () => { + it("silently retries empty turns and keeps the transcript clean", async () => { const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const emptyMessage = createAssistantMessage([{ type: "text", text: " \n" }]); + const thinkingOnly = createAssistantMessage([{ type: "thinking", thinking: "pondering..." }]); + const whitespaceOnly = createAssistantMessage([{ type: "text", text: " \n" }]); const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); - const { streamFn, requests } = streamFnReturning([emptyMessage, goodMessage]); - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - emptyTurnConfig(), - () => {}, - undefined, - streamFn, - ); - - expect(requests.length).toBe(2); - const assistants = messages.filter((message) => message.role === "assistant"); - expect(assistants).toEqual([goodMessage]); - }); - - it("surfaces an error after three consecutive empty turns", async () => { - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const emptyMessage = createAssistantMessage([{ type: "thinking", thinking: "..." }]); - const { streamFn, requests } = streamFnReturning([emptyMessage]); + const { streamFn, requests } = streamFnReturning([thinkingOnly, whitespaceOnly, goodMessage]); const events: AgentEvent[] = []; const messages = await runAgentLoop( @@ -2057,117 +2026,54 @@ describe("empty assistant turn retry", () => { ); expect(requests.length).toBe(3); - const assistants = messages.filter((message) => message.role === "assistant"); - expect(assistants.length).toBe(1); - const finalMessage = assistants[0] as AssistantMessage; - expect(finalMessage.stopReason).toBe("error"); - expect(finalMessage.errorMessage).toMatch(/empty response/i); - expect(events.at(-1)?.type).toBe("agent_end"); - }); - - it("does not retry when non-thinking content exists", async () => { - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const message = createAssistantMessage([ - { type: "thinking", thinking: "..." }, - { type: "text", text: "partial but visible" }, - ]); - const { streamFn, requests } = streamFnReturning([message]); - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - emptyTurnConfig(), - () => {}, - undefined, - streamFn, + // Discarded attempts are neither resent to the provider nor kept as transcript turns. + expect(requests[1].filter((message) => message.role === "assistant")).toEqual([]); + expect(requests[2].filter((message) => message.role === "assistant")).toEqual([]); + expect(messages.filter((message) => message.role === "assistant")).toEqual([goodMessage]); + const assistantEnds = events.filter( + (event) => event.type === "message_end" && event.message.role === "assistant", ); - - expect(requests.length).toBe(1); - const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; - expect(assistant.stopReason).toBe("stop"); + expect(assistantEnds.length).toBe(1); }); - it("does not retry when the turn has tool calls but no text", async () => { - const toolSchema = Type.Object({}); - const tool: AgentTool> = { - name: "work", - label: "Work", - description: "Work", - parameters: toolSchema, - execute: async () => ({ content: [{ type: "text", text: "tool output" }], details: {} }), - }; - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [tool] }; - const toolMessage = createAssistantMessage( - [{ type: "toolCall", id: "tool_1", name: "work", arguments: {} }], - "toolUse", - ); - const goodMessage = createAssistantMessage([{ type: "text", text: "done" }]); - const { streamFn, requests } = streamFnReturning([toolMessage, goodMessage]); - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - emptyTurnConfig(), - () => {}, - undefined, - streamFn, + it("surfaces an error after three consecutive empty turns", async () => { + const { requests, events, assistant } = await runOnce( + createAssistantMessage([{ type: "thinking", thinking: "..." }]), ); - expect(requests.length).toBe(2); - expect(messages.filter((m) => m.role === "toolResult").length).toBe(1); - const assistants = messages.filter((m) => m.role === "assistant"); - expect(assistants).toEqual([toolMessage, goodMessage]); + expect(requests.length).toBe(3); + expect(assistant.stopReason).toBe("error"); + expect(assistant.errorMessage).toMatch(/empty response/i); + expect(events.at(-1)?.type).toBe("agent_end"); }); - it("does not retry an empty length-stop turn", async () => { - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const message = createAssistantMessage([{ type: "thinking", thinking: "..." }], "length"); - const { streamFn, requests } = streamFnReturning([message]); - const events: AgentEvent[] = []; - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - emptyTurnConfig(), - (event) => { - events.push(event); - }, - undefined, - streamFn, + it("does not retry turns with visible content, a length stop, or a silent overflow", async () => { + const visible = await runOnce( + createAssistantMessage([ + { type: "thinking", thinking: "..." }, + { type: "text", text: "partial but visible" }, + ]), ); + expect(visible.requests.length).toBe(1); + expect(visible.assistant.stopReason).toBe("stop"); - expect(requests.length).toBe(1); - const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; - expect(assistant.stopReason).toBe("length"); - expect(assistant.errorMessage).toBeUndefined(); - expect(events.some((event) => event.type === "message_end" && event.message === assistant)).toBe(true); - }); + const lengthStop = await runOnce(createAssistantMessage([{ type: "thinking", thinking: "..." }], "length")); + expect(lengthStop.requests.length).toBe(1); + expect(lengthStop.assistant.stopReason).toBe("length"); + expect(lengthStop.assistant.errorMessage).toBeUndefined(); - it("does not retry a silent context-overflow turn so compaction recovery can see it", async () => { - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const message = createAssistantMessage([{ type: "text", text: "" }]); // z.ai-style silent overflow: normal stop, empty content, input past the window. - message.usage.input = createModel().contextWindow + 1; - const { streamFn, requests } = streamFnReturning([message]); - const events: AgentEvent[] = []; - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - emptyTurnConfig(), - (event) => { - events.push(event); - }, - undefined, - streamFn, + // It must reach message_end untouched so compaction recovery can see it. + const overflowMessage = createAssistantMessage([{ type: "text", text: "" }]); + overflowMessage.usage.input = createModel().contextWindow + 1; + const overflow = await runOnce(overflowMessage); + expect(overflow.requests.length).toBe(1); + expect(overflow.assistant).toBe(overflowMessage); + expect(overflow.assistant.stopReason).toBe("stop"); + expect(overflow.assistant.errorMessage).toBeUndefined(); + expect(overflow.events.some((event) => event.type === "message_end" && event.message === overflowMessage)).toBe( + true, ); - - expect(requests.length).toBe(1); - const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; - expect(assistant).toBe(message); - expect(assistant.stopReason).toBe("stop"); - expect(assistant.errorMessage).toBeUndefined(); - expect(events.some((event) => event.type === "message_end" && event.message === assistant)).toBe(true); }); it("does not retry an aborted empty turn", async () => { From 537e79e646e3fa08af72233f4cc89266fdc46209 Mon Sep 17 00:00:00 2001 From: Sebastian Date: Sun, 30 Aug 2026 14:58:46 +0200 Subject: [PATCH 4/4] test(agent): trim empty-turn retry tests to behavior pins --- packages/agent/test/agent-loop.test.ts | 33 +------------------ .../test/agent-session-recursion.test.ts | 11 ++----- 2 files changed, 3 insertions(+), 41 deletions(-) diff --git a/packages/agent/test/agent-loop.test.ts b/packages/agent/test/agent-loop.test.ts index d5fe7ba40d..7602405e43 100644 --- a/packages/agent/test/agent-loop.test.ts +++ b/packages/agent/test/agent-loop.test.ts @@ -2037,14 +2037,11 @@ describe("empty assistant turn retry", () => { }); it("surfaces an error after three consecutive empty turns", async () => { - const { requests, events, assistant } = await runOnce( - createAssistantMessage([{ type: "thinking", thinking: "..." }]), - ); + const { requests, assistant } = await runOnce(createAssistantMessage([{ type: "thinking", thinking: "..." }])); expect(requests.length).toBe(3); expect(assistant.stopReason).toBe("error"); expect(assistant.errorMessage).toMatch(/empty response/i); - expect(events.at(-1)?.type).toBe("agent_end"); }); it("does not retry turns with visible content, a length stop, or a silent overflow", async () => { @@ -2075,32 +2072,4 @@ describe("empty assistant turn retry", () => { true, ); }); - - it("does not retry an aborted empty turn", async () => { - const controller = new AbortController(); - const context: AgentContext = { systemPrompt: "sys", messages: [], tools: [] }; - const config = emptyTurnConfig(); - let calls = 0; - const streamFn = (() => { - calls += 1; - const stream = new MockAssistantStream(); - queueMicrotask(() => { - controller.abort(); - }); - return stream; - }) as unknown as Parameters[5]; - - const messages = await runAgentLoop( - [createUserMessage("Hello")], - context, - config, - () => {}, - controller.signal, - streamFn, - ); - - expect(calls).toBe(1); - const assistant = messages.find((m) => m.role === "assistant") as AssistantMessage; - expect(assistant.stopReason).toBe("aborted"); - }); }); diff --git a/packages/coding-agent/test/agent-session-recursion.test.ts b/packages/coding-agent/test/agent-session-recursion.test.ts index 9093e5546b..b52d29835d 100644 --- a/packages/coding-agent/test/agent-session-recursion.test.ts +++ b/packages/coding-agent/test/agent-session-recursion.test.ts @@ -1343,15 +1343,12 @@ describe("AgentSession rlm recursion", () => { stopReason: "stop", timestamp: Date.now(), }); - let childStreamCalls = 0; const root = createSession({ - // The child task prompt streams empty turns; the parent's reaction to the - // failure notice answers normally. + // The child task prompt streams empty turns; the parent's reaction to the failure notice answers normally. streamFn: (_model, context) => { if (!userText(context).includes("empty child")) { return streamAnswer("acknowledged"); } - childStreamCalls += 1; const stream = createAssistantMessageEventStream(); queueMicrotask(() => { stream.push({ type: "done", reason: "stop", message: emptyAssistantMessage() }); @@ -1363,18 +1360,14 @@ describe("AgentSession rlm recursion", () => { // child's turn error surfaces immediately instead of after backoff. root.settingsManager.setRetryEnabled(false); - const spawned = await root.runRlmChild("empty child", { name: "empty-worker" }); + await root.runRlmChild("empty child", { name: "empty-worker" }); await vi.waitFor(() => { const failures = root.messages.filter( (message) => message.role === "custom" && message.customType === "rlm_child_failure", ); expect(failures).toHaveLength(1); - expect(failures[0]).toMatchObject({ - content: expect.stringContaining(`RLM child empty-worker (${spawned.rlm_child_id}) failed:`), - }); expect((failures[0] as { content: string }).content).toMatch(/empty response/i); }); - expect(childStreamCalls).toBe(3); expect( root.messages.filter( (message) => message.role === "custom" && message.customType === "rlm_child_terminal_notice",