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 6ed79e1652a0..6476cec50650 100644 --- a/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts +++ b/src/agents/embedded-agent-runner/run.incomplete-turn.test.ts @@ -1088,27 +1088,56 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { }); it("continues once after settled side-effecting tools finish without a final answer", async () => { + const acceptedSessionSpawns = [ + { runId: "child-run", childSessionKey: "agent:main:subagent:child" }, + ]; const toolUseAssistant = { role: "assistant", stopReason: "toolUse", provider: "openai", model: "gpt-5.5", - content: [{ type: "toolCall", id: "tool_1", name: "write", arguments: { path: "note.txt" } }], + content: [ + { type: "toolCall", id: "tool_write", name: "write", arguments: { path: "note.txt" } }, + { type: "toolCall", id: "tool_cron", name: "cron", arguments: { action: "add" } }, + { + type: "toolCall", + id: "tool_spawn", + name: "sessions_spawn", + arguments: { task: "follow up" }, + }, + ], } as unknown as NonNullable; const settledToolResults = [ toolUseAssistant, - { role: "toolResult", toolCallId: "tool_1", toolName: "write", isError: false }, + { role: "toolResult", toolCallId: "tool_write", toolName: "write", isError: false }, + { role: "toolResult", toolCallId: "tool_cron", toolName: "cron", isError: false }, + { + role: "toolResult", + toolCallId: "tool_spawn", + toolName: "sessions_spawn", + isError: false, + }, ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"]; mockedClassifyFailoverReason.mockReturnValue(null); mockedRunEmbeddedAttempt.mockImplementationOnce(async (attemptParams) => { markUserMessagePersisted(attemptParams); return makeAttemptResult({ assistantTexts: [], - toolMetas: [{ toolName: "write", meta: "path=note.txt" }], - itemLifecycle: { startedCount: 1, completedCount: 1, activeCount: 0 }, + latestMcpAppChannelView: { viewId: "view-after-tools" }, + toolMetas: [ + { toolName: "write", meta: "path=note.txt" }, + { toolName: "cron" }, + { toolName: "sessions_spawn" }, + ], + acceptedSessionSpawns, + successfulCronAdds: 1, + itemLifecycle: { startedCount: 3, completedCount: 3, activeCount: 0 }, messagesSnapshot: settledToolResults, lastAssistant: toolUseAssistant, currentAttemptAssistant: toolUseAssistant, + codeModeEngaged: true, + assistantTurns: 1, + bridgeCalls: { search: 1, describe: 2, call: 3 }, }); }); const finalAssistant = { @@ -1139,6 +1168,19 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(2); expect(result.payloads?.[0]?.text).toBe("Write completed. Here is the final answer."); + expect(result.latestMcpAppChannelView).toEqual({ viewId: "view-after-tools" }); + expect(result.successfulCronAdds).toBe(1); + expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); + expect(result.meta.toolSummary).toEqual({ + calls: 3, + tools: ["write", "cron", "sessions_spawn"], + failures: 0, + }); + expect(result.meta.agentMeta).toMatchObject({ + codeModeEngaged: true, + assistantTurns: 2, + bridgeCalls: { search: 1, describe: 2, call: 3 }, + }); const secondCall = runAttemptCall(1); expect(secondCall.prompt).toBe(SETTLED_TOOL_TERMINAL_CONTINUATION_INSTRUCTION); expect(secondCall.disableTools).toBe(true); @@ -1205,7 +1247,11 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { ] as unknown as EmbeddedRunAttemptResult["messagesSnapshot"], lastAssistant: toolUseAssistant, currentAttemptAssistant: toolUseAssistant, - lastToolError: { toolName: "exec", error: "post-processing error" }, + lastToolError: { + toolName: "exec", + error: "post-processing error", + errorCode: "SYSTEM_RUN_DENIED", + }, }); }); const finalAssistant = { @@ -1244,6 +1290,18 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { expect(finalizationCall.prompt).toContain( "If any tool failed, state that failure plainly and do not claim it succeeded.", ); + expect(result.meta.failureSignal).toEqual( + runPolicy.trigger === "cron" + ? { + kind: "execution_denied", + source: "tool", + toolName: "exec", + code: "SYSTEM_RUN_DENIED", + message: "post-processing error", + fatalForCron: true, + } + : undefined, + ); }); it("preserves a structured visible failed-tool payload without finalizing (#118274)", async () => { diff --git a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts index 08e82a38c195..0ece7f7df429 100644 --- a/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts +++ b/src/agents/embedded-agent-runner/run/settled-turn-finalization.ts @@ -16,7 +16,10 @@ import { type EmbeddedRunTerminalState, } from "./terminal-outcome.js"; import { prepareEmbeddedRunTerminal } from "./terminal-preparation.js"; -import { resolveSettledTurnFinalizationRequest } from "./terminal-resolution.js"; +import { + copyAttemptDeliveryState, + resolveSettledTurnFinalizationRequest, +} from "./terminal-resolution.js"; import type { EmbeddedRunAttemptParams, EmbeddedRunAttemptResult } from "./types.js"; type TerminalPreparationInput = Parameters[0]; @@ -88,6 +91,7 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { finalizationSucceeded: false, }; } + const settledFailureSignal = prepared.failureSignal; const runParams = input.terminalBase.runParams; const errorContext = input.terminalBase.activeErrorContext; @@ -114,7 +118,7 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { }), signalOwnedInterruption: false, }; - prepared = prepareEmbeddedRunTerminal({ + const finalizedPrepared = prepareEmbeddedRunTerminal({ ...input.terminalBase, attempt, currentAttemptCompletedAssistant: attempt.currentAttemptCompletedAssistant, @@ -123,6 +127,8 @@ export async function prepareTerminalWithSettledTurnFinalization(input: { lastRunPromptUsage, terminalState, }); + // A failure-honest final answer cannot turn a settled cron denial into success. + prepared = { ...finalizedPrepared, failureSignal: settledFailureSignal }; return { attempt, attemptAssistant: attempt.currentAttemptAssistant, @@ -188,8 +194,8 @@ function buildSettledTurnFinalizationAttemptResult(input: { }): EmbeddedRunAttemptResult { const { result, settledAttempt } = input; const text = resolveSettledTurnFinalizationText(result); - // Finalization bypasses ordinary attempt normalization. Rebuild only the - // terminal projection so settled side effects and retry state cannot leak in. + // Finalization replaces terminal ownership, not facts from already-settled tools. + // Keep those facts while replay, abort, and lifecycle state remain finalizer-local. return { terminal: { kind: "ok" }, sessionIdUsed: settledAttempt.sessionIdUsed, @@ -199,6 +205,7 @@ function buildSettledTurnFinalizationAttemptResult(input: { runtimeArtifact: settledAttempt.runtimeArtifact, systemPromptReport: settledAttempt.systemPromptReport, finalPromptText: input.prompt, + ...copyAttemptDeliveryState(settledAttempt), messagesSnapshot: [...settledAttempt.messagesSnapshot, result.assistant], assistantTexts: [text], assistantTranscriptOwned: result.assistantTranscriptOwned, @@ -207,19 +214,12 @@ function buildSettledTurnFinalizationAttemptResult(input: { lastAssistant: result.assistant, currentAttemptAssistant: result.assistant, currentAttemptCompletedAssistant: result.assistant, - toolMetas: [], - acceptedSessionSpawns: [], - didSendViaMessagingTool: false, - didDeliverSourceReplyViaMessageTool: false, - didSendDeterministicApprovalPrompt: false, - messagingToolSentTexts: [], - messagingToolSentMediaUrls: [], - messagingToolSentTargets: [], - messagingToolSourceReplyPayloads: [], + toolMetas: settledAttempt.toolMetas, hasToolMediaBlockReply: false, - successfulCronAdds: 0, cloudCodeAssistFormatError: false, attemptUsage: result.usage, + codeModeEngaged: settledAttempt.codeModeEngaged, + assistantTurns: 1, replayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, currentAttemptReplayMetadata: { hadPotentialSideEffects: false, replaySafe: true }, itemLifecycle: { startedCount: 0, completedCount: 0, activeCount: 0 },