diff --git a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts index 496ee099a981..3449d3435716 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -1528,6 +1528,63 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); + it("surfaces stall on clean stop with only an unsigned thinking payload (payloadCount=1, no visible text)", () => { + // Regression: unsigned thinking payloads increment payloadCount but carry no + // user-visible content. The visible-text guard must not suppress incomplete-turn + // detection when the model produced only a thinking block and no answer. (#89787) + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 1, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "qwen3.6-35b-a3b", + content: [ + { + type: "thinking", + thinking: "let me plan the tool calls I need to make...", + // no signature — unsigned thinking block + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toContain("couldn't generate a response"); + }); + + it("does not surface a stall when unsigned thinking accompanies visible text (payloadCount=1)", () => { + // When the model emits both a thinking block and a visible text answer, the turn + // succeeded and no stall should be surfaced even though thinking is unsigned. + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 1, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: ["Here is the answer to your question."], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "qwen3.6-35b-a3b", + content: [ + { + type: "thinking", + thinking: "let me answer this...", + }, + { type: "text", text: "Here is the answer to your question." }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(incompleteTurnText).toBeNull(); + }); + it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedAgent (#76477)", async () => { mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockResolvedValueOnce( @@ -1696,6 +1753,59 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION); }); + it("retries unsigned thinking-only turns via the reasoning-only path (openai-completions)", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "openai", + modelId: "qwen3.6-35b-a3b", + modelApi: "openai-completions", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "openai", + model: "qwen3.6-35b-a3b", + content: [ + { + type: "thinking", + thinking: "let me plan the tool calls I need to make...", + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION); + }); + + it("retries unsigned thinking-only Ollama turns via the reasoning-only path", () => { + const retryInstruction = resolveReasoningOnlyRetryInstruction({ + provider: "ollama", + modelId: "gemma4:31b", + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "ollama", + model: "gemma4:31b", + content: [ + { + type: "thinking", + thinking: "internal reasoning", + }, + ], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBe(REASONING_ONLY_RETRY_INSTRUCTION); + }); + it("retries unsigned-thinking Ollama turns via the empty-response path", () => { const retryInstruction = resolveEmptyResponseRetryInstruction({ provider: "ollama", diff --git a/src/agents/embedded-agent-runner/run/incomplete-turn.ts b/src/agents/embedded-agent-runner/run/incomplete-turn.ts index fab5f21e356b..1f83c144f95f 100644 --- a/src/agents/embedded-agent-runner/run/incomplete-turn.ts +++ b/src/agents/embedded-agent-runner/run/incomplete-turn.ts @@ -280,9 +280,17 @@ export function resolveIncompleteTurnPayloadText(params: { // turn check in that case — the final post-tool response was never // produced. (#76477) const toolUseTerminal = params.attempt.lastAssistant?.stopReason === "toolUse"; + const assistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant; + // Unsigned thinking payloads count toward payloadCount but carry no user-visible + // content; bypass the visible-text guard when unsigned thinking was the only output + // so that incomplete-turn stall detection fires below. (#89787) + const unsignedThinkingOnlyTerminal = + params.payloadCount !== 0 && + !joinAssistantTexts(params.attempt.assistantTexts).length && + isUnsignedThinkingOnlyAssistantTurn(assistant); if ( - (params.payloadCount !== 0 && !toolUseTerminal) || + (params.payloadCount !== 0 && !toolUseTerminal && !unsignedThinkingOnlyTerminal) || (params.aborted && params.externalAbort) || params.timedOut || params.attempt.clientToolCalls || @@ -314,9 +322,7 @@ export function resolveIncompleteTurnPayloadText(params: { hasAssistantVisibleText: params.payloadCount > 0, lastAssistant: params.attempt.lastAssistant, }); - const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn( - params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant, - ); + const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn(assistant); const emptyResponseAssistant = isEmptyResponseAssistantTurn({ payloadCount: params.payloadCount, attempt: params.attempt, @@ -324,6 +330,7 @@ export function resolveIncompleteTurnPayloadText(params: { if ( !incompleteTerminalAssistant && !reasoningOnlyAssistant && + !unsignedThinkingOnlyTerminal && !emptyResponseAssistant && stopReason !== "error" ) { @@ -534,6 +541,20 @@ function isReasoningOnlyAssistantTurn(message: unknown): boolean { return assessLastAssistantMessage(message as AgentMessage) === "incomplete-text"; } +// Unsigned thinking blocks have no cryptographic signature; assessLastAssistantMessage +// returns "incomplete-thinking" for them. Empty content also returns "incomplete-thinking", +// so the content.length > 0 guard is required to distinguish the two cases. +function isUnsignedThinkingOnlyAssistantTurn(message: unknown): boolean { + if (message == null || typeof message !== "object") { + return false; + } + const content = (message as { content?: unknown }).content; + if (!Array.isArray(content) || content.length === 0) { + return false; + } + return assessLastAssistantMessage(message as AgentMessage) === "incomplete-thinking"; +} + function isEmptyResponseAssistantTurn(params: { payloadCount: number; attempt: Pick< @@ -669,7 +690,7 @@ export function resolveReasoningOnlyRetryInstruction(params: { if (assistant?.stopReason === "error") { return null; } - if (!isReasoningOnlyAssistantTurn(assistant)) { + if (!isReasoningOnlyAssistantTurn(assistant) && !isUnsignedThinkingOnlyAssistantTurn(assistant)) { return null; }