From 5e084b3a8d3dff278fbcaaddd787950302910f56 Mon Sep 17 00:00:00 2001 From: samzong Date: Fri, 22 May 2026 01:02:57 +0800 Subject: [PATCH] fix(agents): preserve accepted spawn terminal success Signed-off-by: samzong --- src/agents/accepted-session-spawn.ts | 35 +++++ .../pi-embedded-runner/delivery-evidence.ts | 6 + .../result-fallback-classifier.test.ts | 22 +++ .../run.incomplete-turn.test.ts | 147 +++++++++++++++++- .../run.overflow-compaction.fixture.ts | 3 + src/agents/pi-embedded-runner/run.ts | 16 +- .../run/attempt-trajectory-status.test.ts | 16 ++ .../run/attempt-trajectory-status.ts | 7 + .../attempt.spawn-workspace.test-support.ts | 1 + src/agents/pi-embedded-runner/run/attempt.ts | 11 +- .../pi-embedded-runner/run/incomplete-turn.ts | 12 +- src/agents/pi-embedded-runner/run/types.ts | 2 + src/agents/pi-embedded-runner/types.ts | 3 + ...edded-subscribe.handlers.lifecycle.test.ts | 25 +++ ...i-embedded-subscribe.handlers.lifecycle.ts | 2 + ...-embedded-subscribe.handlers.tools.test.ts | 74 +++++++++ .../pi-embedded-subscribe.handlers.tools.ts | 10 +- .../pi-embedded-subscribe.handlers.types.ts | 3 + ...session.subscribeembeddedpisession.test.ts | 43 +++++ src/agents/pi-embedded-subscribe.ts | 3 + .../pi-tool-handler-state.test-helpers.ts | 1 + 21 files changed, 432 insertions(+), 10 deletions(-) create mode 100644 src/agents/accepted-session-spawn.ts create mode 100644 src/agents/pi-embedded-runner/result-fallback-classifier.test.ts diff --git a/src/agents/accepted-session-spawn.ts b/src/agents/accepted-session-spawn.ts new file mode 100644 index 000000000000..f7366345fc37 --- /dev/null +++ b/src/agents/accepted-session-spawn.ts @@ -0,0 +1,35 @@ +import { normalizeOptionalString } from "../shared/string-coerce.js"; + +export type AcceptedSessionSpawn = { + runId: string; + childSessionKey: string; +}; + +function asRecord(value: unknown): Record | undefined { + return value && typeof value === "object" && !Array.isArray(value) + ? (value as Record) + : undefined; +} + +export function normalizeAcceptedSessionSpawnResult(result: unknown): AcceptedSessionSpawn | null { + const details = asRecord(asRecord(result)?.details); + if (!details || details.status !== "accepted") { + return null; + } + const runId = normalizeOptionalString(details.runId); + const childSessionKey = normalizeOptionalString(details.childSessionKey); + if (!runId || !childSessionKey) { + return null; + } + return { runId, childSessionKey }; +} + +export function hasAcceptedSessionSpawn( + acceptedSessionSpawns?: readonly AcceptedSessionSpawn[], +): boolean { + return (acceptedSessionSpawns ?? []).some((spawn) => { + return Boolean( + normalizeOptionalString(spawn.runId) && normalizeOptionalString(spawn.childSessionKey), + ); + }); +} diff --git a/src/agents/pi-embedded-runner/delivery-evidence.ts b/src/agents/pi-embedded-runner/delivery-evidence.ts index 398d9423d985..0ce37c3538dd 100644 --- a/src/agents/pi-embedded-runner/delivery-evidence.ts +++ b/src/agents/pi-embedded-runner/delivery-evidence.ts @@ -1,3 +1,5 @@ +import { hasAcceptedSessionSpawn } from "../accepted-session-spawn.js"; + type AgentPayloadLike = { text?: unknown; mediaUrl?: unknown; @@ -19,6 +21,7 @@ export type AgentDeliveryEvidence = { messagingToolSentTexts?: unknown; messagingToolSentMediaUrls?: unknown; messagingToolSentTargets?: unknown; + acceptedSessionSpawns?: unknown; successfulCronAdds?: unknown; meta?: { toolSummary?: { @@ -129,6 +132,7 @@ function hasAgentDeliveryEvidenceShape(value: object): boolean { "messagingToolSentTexts" in value || "messagingToolSentMediaUrls" in value || "messagingToolSentTargets" in value || + "acceptedSessionSpawns" in value || "successfulCronAdds" in value || "meta" in value ); @@ -186,6 +190,8 @@ export function hasCommittedMessagingToolDeliveryEvidence( export function hasOutboundDeliveryEvidence(result: AgentDeliveryEvidence): boolean { return ( hasMessagingToolDeliveryEvidence(result) || + (Array.isArray(result.acceptedSessionSpawns) && + hasAcceptedSessionSpawn(result.acceptedSessionSpawns)) || hasPositiveNumber(result.successfulCronAdds) || hasPositiveNumber(result.meta?.toolSummary?.calls) ); diff --git a/src/agents/pi-embedded-runner/result-fallback-classifier.test.ts b/src/agents/pi-embedded-runner/result-fallback-classifier.test.ts new file mode 100644 index 000000000000..d56569cbb91d --- /dev/null +++ b/src/agents/pi-embedded-runner/result-fallback-classifier.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from "vitest"; +import { classifyEmbeddedPiRunResultForModelFallback } from "./result-fallback-classifier.js"; + +describe("classifyEmbeddedPiRunResultForModelFallback", () => { + it("does not fallback when sessions_spawn accepted a child session", () => { + expect( + classifyEmbeddedPiRunResultForModelFallback({ + provider: "mock-openai", + model: "gpt-5.5", + result: { + meta: { durationMs: 1 }, + acceptedSessionSpawns: [ + { + runId: "run-child", + childSessionKey: "agent:qa:subagent:child", + }, + ], + }, + }), + ).toBeNull(); + }); +}); diff --git a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts index 0eebe0193a6a..89e0fea8b994 100644 --- a/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts +++ b/src/agents/pi-embedded-runner/run.incomplete-turn.test.ts @@ -1,6 +1,9 @@ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; -import { hasCommittedMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; +import { + hasCommittedMessagingToolDeliveryEvidence, + hasOutboundDeliveryEvidence, +} from "./delivery-evidence.js"; import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; import { loadRunOverflowCompactionHarness, @@ -1484,6 +1487,34 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); }); + it("does not retry empty turns after an accepted sessions_spawn delivery", () => { + const retryInstruction = resolveEmptyResponseRetryInstruction({ + provider: "ollama", + modelId: "gemma4:31b", + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult({ + assistantTexts: [], + acceptedSessionSpawns: [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ], + lastAssistant: { + role: "assistant", + stopReason: "end_turn", + provider: "ollama", + model: "gemma4:31b", + content: [{ type: "text", text: "" }], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + }); + + expect(retryInstruction).toBeNull(); + }); + it("retries generic empty OpenAI-compatible turns from custom endpoints", () => { const retryInstruction = resolveEmptyResponseRetryInstruction({ provider: "llama-cpp-local", @@ -1654,6 +1685,100 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { expect(incompleteTurnText).toBeNull(); }); + it("suppresses the incomplete-turn warning after an accepted sessions_spawn terminal success", () => { + const attemptWithAcceptedSpawn: Partial & { + acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>; + } = { + assistantTexts: [], + acceptedSessionSpawns: [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "anthropic", + model: "sonnet-4.6", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }; + + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult(attemptWithAcceptedSpawn), + }); + + expect(incompleteTurnText).toBeNull(); + }); + + it("still returns a timeout payload when the parent prompt times out after an accepted sessions_spawn", async () => { + const acceptedSessionSpawns = [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ]; + mockedClassifyFailoverReason.mockReturnValue(null); + mockedRunEmbeddedAttempt.mockResolvedValueOnce( + makeAttemptResult({ + assistantTexts: [], + acceptedSessionSpawns, + timedOut: true, + lastAssistant: { + role: "assistant", + stopReason: "toolUse", + provider: "openai", + model: "gpt-5.4", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }), + ); + + const result = await runEmbeddedPiAgent({ + ...overflowBaseRunParams, + provider: "openai", + model: "gpt-5.4", + runId: "run-timeout-after-accepted-spawn", + }); + + expect(result.payloads).toEqual([ + { + text: "Request timed out before a response was generated. Please try again, or increase `agents.defaults.timeoutSeconds` in your config.", + isError: true, + }, + ]); + expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); + }); + + it("still surfaces the incomplete-turn warning without an accepted sessions_spawn success", () => { + const attemptWithMalformedSpawn: Partial & { + acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>; + } = { + assistantTexts: [], + acceptedSessionSpawns: [], + lastAssistant: { + role: "assistant", + stopReason: "stop", + provider: "anthropic", + model: "sonnet-4.6", + content: [], + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], + }; + + const incompleteTurnText = resolveIncompleteTurnPayloadText({ + payloadCount: 0, + aborted: false, + timedOut: false, + attempt: makeAttemptResult(attemptWithMalformedSpawn), + }); + + expect(incompleteTurnText).toContain("couldn't generate a response"); + }); + it("still surfaces the incomplete-turn warning when no messaging delivery was committed", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, @@ -1738,6 +1863,26 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); }); + it("treats accepted sessions_spawn as replay-invalid outbound delivery", () => { + const acceptedSessionSpawns = [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ]; + + expect( + buildAttemptReplayMetadata({ + toolMetas: [], + didSendViaMessagingTool: false, + messagingToolSentTexts: [], + messagingToolSentMediaUrls: [], + acceptedSessionSpawns, + }), + ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); + expect(hasOutboundDeliveryEvidence({ acceptedSessionSpawns })).toBe(true); + }); + it("leaves committed delivery plus tool errors to the tool-error payload path", () => { const incompleteTurnText = resolveIncompleteTurnPayloadText({ payloadCount: 0, diff --git a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts index 29bc3a190507..f043c1f5beb0 100644 --- a/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts +++ b/src/agents/pi-embedded-runner/run.overflow-compaction.fixture.ts @@ -39,6 +39,7 @@ export function makeAttemptResult( const messagingToolSentMediaUrls = overrides.messagingToolSentMediaUrls ?? []; const messagingToolSentTargets = overrides.messagingToolSentTargets ?? []; const successfulCronAdds = overrides.successfulCronAdds; + const acceptedSessionSpawns = overrides.acceptedSessionSpawns ?? []; return { aborted: false, externalAbort: false, @@ -51,6 +52,7 @@ export function makeAttemptResult( sessionIdUsed: "test-session", assistantTexts: ["Hello!"], toolMetas, + acceptedSessionSpawns, lastAssistant: undefined, messagesSnapshot: [], replayMetadata: @@ -61,6 +63,7 @@ export function makeAttemptResult( messagingToolSentTexts, messagingToolSentMediaUrls, messagingToolSentTargets, + acceptedSessionSpawns, successfulCronAdds, }), itemLifecycle: { diff --git a/src/agents/pi-embedded-runner/run.ts b/src/agents/pi-embedded-runner/run.ts index 10fff73d3d85..180a84d4f3c2 100644 --- a/src/agents/pi-embedded-runner/run.ts +++ b/src/agents/pi-embedded-runner/run.ts @@ -106,7 +106,10 @@ import { } from "./compaction-safety-timeout.js"; import { resolveContextEngineCapabilities } from "./context-engine-capabilities.js"; import { runContextEngineMaintenance } from "./context-engine-maintenance.js"; -import { hasMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; +import { + hasMessagingToolDeliveryEvidence, + hasOutboundDeliveryEvidence, +} from "./delivery-evidence.js"; import { resolveEmbeddedRunFailureSignal } from "./failure-signal.js"; import { resolveGlobalLane, resolveSessionLane } from "./lanes.js"; import { log } from "./logger.js"; @@ -251,6 +254,7 @@ function normalizeEmbeddedRunAttemptResult( const raw = attempt as EmbeddedRunAttemptForRunner & { assistantTexts?: EmbeddedRunAttemptForRunner["assistantTexts"] | null; toolMetas?: EmbeddedRunAttemptForRunner["toolMetas"] | null; + acceptedSessionSpawns?: EmbeddedRunAttemptForRunner["acceptedSessionSpawns"] | null; messagesSnapshot?: EmbeddedRunAttemptForRunner["messagesSnapshot"] | null; messagingToolSentTexts?: EmbeddedRunAttemptForRunner["messagingToolSentTexts"] | null; messagingToolSentMediaUrls?: EmbeddedRunAttemptForRunner["messagingToolSentMediaUrls"] | null; @@ -264,6 +268,7 @@ function normalizeEmbeddedRunAttemptResult( ...attempt, assistantTexts: raw.assistantTexts ?? [], toolMetas: raw.toolMetas ?? [], + acceptedSessionSpawns: raw.acceptedSessionSpawns ?? [], messagesSnapshot: raw.messagesSnapshot ?? [], messagingToolSentTexts: raw.messagingToolSentTexts ?? [], messagingToolSentMediaUrls: raw.messagingToolSentMediaUrls ?? [], @@ -283,7 +288,7 @@ function hasCompletedModelProgressForIdleBreaker(attempt: EmbeddedRunAttemptForR attempt.assistantTexts.some((text) => text.trim().length > 0) || attempt.toolMetas.length > 0 || (attempt.clientToolCalls?.length ?? 0) > 0 || - hasMessagingToolDeliveryEvidence(attempt) || + hasOutboundDeliveryEvidence(attempt) || attempt.itemLifecycle.completedCount > 0 ); } @@ -1652,7 +1657,7 @@ export async function runEmbeddedPiAgent( ? sessionLastAssistant.errorMessage?.trim() || formattedAssistantErrorText : undefined; const canRestartForLiveSwitch = - !hasMessagingToolDeliveryEvidence(attempt) && + !hasOutboundDeliveryEvidence(attempt) && !attempt.didSendDeterministicApprovalPrompt && !attempt.lastToolError && (attempt.toolMetas?.length ?? 0) === 0 && @@ -2739,6 +2744,7 @@ export async function runEmbeddedPiAgent( messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, heartbeatToolResponse: attempt.heartbeatToolResponse, successfulCronAdds: attempt.successfulCronAdds, + acceptedSessionSpawns: attempt.acceptedSessionSpawns, }; } @@ -2962,6 +2968,7 @@ export async function runEmbeddedPiAgent( messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, heartbeatToolResponse: attempt.heartbeatToolResponse, successfulCronAdds: attempt.successfulCronAdds, + acceptedSessionSpawns: attempt.acceptedSessionSpawns, }; } if (reasoningOnlyRetriesExhausted && !finalAssistantVisibleText) { @@ -3014,6 +3021,7 @@ export async function runEmbeddedPiAgent( messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, heartbeatToolResponse: attempt.heartbeatToolResponse, successfulCronAdds: attempt.successfulCronAdds, + acceptedSessionSpawns: attempt.acceptedSessionSpawns, }; } if ( @@ -3125,6 +3133,7 @@ export async function runEmbeddedPiAgent( messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, heartbeatToolResponse: attempt.heartbeatToolResponse, successfulCronAdds: attempt.successfulCronAdds, + acceptedSessionSpawns: attempt.acceptedSessionSpawns, }; } @@ -3241,6 +3250,7 @@ export async function runEmbeddedPiAgent( messagingToolSourceReplyPayloads: attempt.messagingToolSourceReplyPayloads, heartbeatToolResponse: attempt.heartbeatToolResponse, successfulCronAdds: attempt.successfulCronAdds, + acceptedSessionSpawns: attempt.acceptedSessionSpawns, }; } } finally { diff --git a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts b/src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts index 990b16e44290..514d3c15101b 100644 --- a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts +++ b/src/agents/pi-embedded-runner/run/attempt-trajectory-status.test.ts @@ -50,6 +50,22 @@ describe("attempt trajectory status", () => { ).toEqual({ status: "success" }); }); + it("keeps accepted session spawns as terminal progress", () => { + expect( + resolveAttemptTrajectoryTerminal( + baseParams({ + acceptedSessionSpawns: [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ], + lastAssistantStopReason: "toolUse", + }), + ), + ).toEqual({ status: "success" }); + }); + it("does not treat an uncommitted messaging tool attempt as delivery", () => { expect( resolveAttemptTrajectoryTerminal( diff --git a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts b/src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts index 264a0f703205..3329db48e7e2 100644 --- a/src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts +++ b/src/agents/pi-embedded-runner/run/attempt-trajectory-status.ts @@ -1,3 +1,8 @@ +import { + hasAcceptedSessionSpawn, + type AcceptedSessionSpawn, +} from "../../accepted-session-spawn.js"; + export type AttemptTrajectoryTerminalStatus = "success" | "error" | "interrupted"; export const NON_DELIVERABLE_TERMINAL_TURN_REASON = "non_deliverable_terminal_turn"; @@ -20,6 +25,7 @@ export type ResolveAttemptTrajectoryTerminalParams = { messagingToolSentTargets: unknown[]; successfulCronAdds: number; synthesizedPayloadCount: number; + acceptedSessionSpawns?: readonly AcceptedSessionSpawn[]; heartbeatToolResponse?: unknown; clientToolCalls?: Array; yieldDetected?: boolean; @@ -80,6 +86,7 @@ export function resolveAttemptTrajectoryTerminal( params.emptyAssistantReplyIsSilent === true || params.didSendDeterministicApprovalPrompt || hasCommittedMessagingDeliveryEvidence(params) || + hasAcceptedSessionSpawn(params.acceptedSessionSpawns) || params.synthesizedPayloadCount > 0 || params.heartbeatToolResponse !== undefined || (params.clientToolCalls?.length ?? 0) > 0 || diff --git a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts index 6f37e065fac2..915d2cc9ec08 100644 --- a/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts +++ b/src/agents/pi-embedded-runner/run/attempt.spawn-workspace.test-support.ts @@ -100,6 +100,7 @@ export function createSubscriptionMock(): SubscriptionMock { unsubscribe: () => {}, setTerminalLifecycleMeta: () => {}, waitForCompactionRetry: async () => {}, + getAcceptedSessionSpawns: () => [], getMessagingToolSentTexts: () => [] as string[], getMessagingToolSentMediaUrls: () => [] as string[], getMessagingToolSentTargets: () => [] as MessagingToolSend[], diff --git a/src/agents/pi-embedded-runner/run/attempt.ts b/src/agents/pi-embedded-runner/run/attempt.ts index 250804ee0a16..9a4fe2a81904 100644 --- a/src/agents/pi-embedded-runner/run/attempt.ts +++ b/src/agents/pi-embedded-runner/run/attempt.ts @@ -3191,9 +3191,8 @@ export async function runEmbeddedAttempt( prompt: string, options?: Parameters[1], ): Promise => - withOwnedSessionTranscriptWrites( - ownedTranscriptWriteContext, - async () => abortable(trackPromptSettlePromise(activeSession.prompt(prompt, options))), + withOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, async () => + abortable(trackPromptSettlePromise(activeSession.prompt(prompt, options))), ); const onBlockReply = params.onBlockReply ? bindOwnedSessionTranscriptWrites(ownedTranscriptWriteContext, params.onBlockReply) @@ -3245,6 +3244,7 @@ export async function runEmbeddedAttempt( const { assistantTexts, toolMetas, + getAcceptedSessionSpawns, runToolLifecycle, unsubscribe, waitForCompactionRetry, @@ -4638,11 +4638,13 @@ export async function runEmbeddedAttempt( }); } + const acceptedSessionSpawns = getAcceptedSessionSpawns(); const observedReplayMetadata = buildAttemptReplayMetadata({ toolMetas: toolMetasNormalized, didSendViaMessagingTool: didSendViaMessagingTool(), messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), + acceptedSessionSpawns, successfulCronAdds: getSuccessfulCronAdds(), }); const pendingToolMediaReply = getPendingToolMediaReply(); @@ -4701,6 +4703,7 @@ export async function runEmbeddedAttempt( messagingToolSentTexts: getMessagingToolSentTexts(), messagingToolSentMediaUrls: getMessagingToolSentMediaUrls(), messagingToolSentTargets: getMessagingToolSentTargets(), + acceptedSessionSpawns, lastToolError, lastAssistant, replayMetadata, @@ -4726,6 +4729,7 @@ export async function runEmbeddedAttempt( messagingToolSentTargets: getMessagingToolSentTargets(), successfulCronAdds: getSuccessfulCronAdds(), synthesizedPayloadCount, + acceptedSessionSpawns, heartbeatToolResponse, clientToolCalls: completedClientToolCalls, yieldDetected, @@ -4815,6 +4819,7 @@ export async function runEmbeddedAttempt( messagesSnapshot, assistantTexts, toolMetas: toolMetasNormalized, + acceptedSessionSpawns, lastAssistant, currentAttemptAssistant, lastToolError, diff --git a/src/agents/pi-embedded-runner/run/incomplete-turn.ts b/src/agents/pi-embedded-runner/run/incomplete-turn.ts index 767abda2a22e..3cddf36b34ff 100644 --- a/src/agents/pi-embedded-runner/run/incomplete-turn.ts +++ b/src/agents/pi-embedded-runner/run/incomplete-turn.ts @@ -6,6 +6,7 @@ import { } from "../../../auto-reply/tokens.js"; import type { EmbeddedPiExecutionContract } from "../../../config/types.agent-defaults.js"; import { normalizeLowercaseStringOrEmpty } from "../../../shared/string-coerce.js"; +import { hasAcceptedSessionSpawn } from "../../accepted-session-spawn.js"; import { collectTextContentBlocks } from "../../content-blocks.js"; import { isStrictAgenticSupportedProviderModel, @@ -29,7 +30,7 @@ type ReplayMetadataAttempt = Pick< | "messagingToolSentMediaUrls" | "successfulCronAdds" > & - Partial>; + Partial>; type IncompleteTurnAttempt = Pick< EmbeddedRunAttemptResult, @@ -47,7 +48,8 @@ type IncompleteTurnAttempt = Pick< | "replayMetadata" | "promptErrorSource" | "timedOutDuringCompaction" ->; +> & + Partial>; type PlanningOnlyAttempt = Pick< EmbeddedRunAttemptResult, @@ -206,6 +208,7 @@ export function buildAttemptReplayMetadata( const hadPotentialSideEffects = hadMutatingTools || hasMessagingToolDeliveryEvidence(params) || + hasAcceptedSessionSpawn(params.acceptedSessionSpawns) || (params.successfulCronAdds ?? 0) > 0; return { hadPotentialSideEffects, @@ -252,6 +255,10 @@ export function resolveIncompleteTurnPayloadText(params: { return null; } + if (hasAcceptedSessionSpawn(params.attempt.acceptedSessionSpawns)) { + return null; + } + const stopReason = params.attempt.lastAssistant?.stopReason; const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({ hasAssistantVisibleText: params.payloadCount > 0, @@ -484,6 +491,7 @@ function shouldSkipPlanningOnlyRetry(params: { params.attempt.yieldDetected || params.attempt.didSendDeterministicApprovalPrompt || params.attempt.lastToolError || + hasAcceptedSessionSpawn(params.attempt.acceptedSessionSpawns) || resolveAttemptReplayMetadata(params.attempt).hadPotentialSideEffects, ); } diff --git a/src/agents/pi-embedded-runner/run/types.ts b/src/agents/pi-embedded-runner/run/types.ts index 7459fb278527..a36b9bf11308 100644 --- a/src/agents/pi-embedded-runner/run/types.ts +++ b/src/agents/pi-embedded-runner/run/types.ts @@ -7,6 +7,7 @@ import type { SessionSystemPromptReport } from "../../../config/sessions/types.j import type { ContextEngine, ContextEnginePromptCacheInfo } from "../../../context-engine/types.js"; import type { DiagnosticTraceContext } from "../../../infra/diagnostic-trace-context.js"; import type { PluginHookBeforeAgentStartResult } from "../../../plugins/hook-before-agent-start.types.js"; +import type { AcceptedSessionSpawn } from "../../accepted-session-spawn.js"; import type { AuthProfileStore } from "../../auth-profiles/types.js"; import type { MessagingToolSend, @@ -120,6 +121,7 @@ export type EmbeddedRunAttemptResult = { messagesSnapshot: AgentMessage[]; assistantTexts: string[]; toolMetas: Array<{ toolName: string; meta?: string }>; + acceptedSessionSpawns?: AcceptedSessionSpawn[]; lastAssistant: AssistantMessage | undefined; currentAttemptAssistant?: AssistantMessage | undefined; lastToolError?: ToolErrorSummary; diff --git a/src/agents/pi-embedded-runner/types.ts b/src/agents/pi-embedded-runner/types.ts index 8d9504be94a0..07ca5bcd3cb9 100644 --- a/src/agents/pi-embedded-runner/types.ts +++ b/src/agents/pi-embedded-runner/types.ts @@ -1,6 +1,7 @@ import type { HeartbeatToolResponse } from "../../auto-reply/heartbeat-tool-response.js"; import type { CliSessionBinding, SessionSystemPromptReport } from "../../config/sessions/types.js"; import type { DiagnosticTraceContext } from "../../infra/diagnostic-trace-context.js"; +import type { AcceptedSessionSpawn } from "../accepted-session-spawn.js"; import type { FallbackAttempt } from "../model-fallback.types.js"; import type { MessagingToolSend, @@ -191,6 +192,8 @@ export type EmbeddedPiRunResult = { messagingToolSentTargets?: MessagingToolSend[]; // Message-tool replies delivered to the active internal UI source. messagingToolSourceReplyPayloads?: MessagingToolSourceReplyPayload[]; + // Child sessions successfully accepted by sessions_spawn during the run. + acceptedSessionSpawns?: AcceptedSessionSpawn[]; // Structured heartbeat outcome recorded by the heartbeat response tool. heartbeatToolResponse?: HeartbeatToolResponse; // Count of successful cron.add tool calls in this run. diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts index 4ef00ea5cbeb..4ae5ecaeacba 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.test.ts @@ -367,6 +367,31 @@ describe("handleAgentEnd", () => { }); }); + it("keeps accepted session spawns from being marked abandoned", async () => { + const onAgentEvent = vi.fn(); + const ctx = createContext(undefined, { onAgentEvent }); + ctx.state.replayState = { ...ctx.state.replayState, replayInvalid: true }; + ctx.state.livenessState = "working"; + ctx.state.assistantTexts = []; + ctx.state.acceptedSessionSpawns = [ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ]; + + await handleAgentEnd(ctx); + + expect(onAgentEvent).toHaveBeenCalledWith({ + stream: "lifecycle", + data: { + phase: "end", + livenessState: "working", + replayInvalid: true, + }, + }); + }); + it("flushes orphaned tool media as a media-only block reply", async () => { const ctx = createContext(undefined); ctx.state.pendingToolMediaUrls = ["/tmp/reply.opus"]; diff --git a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts index 1eba7b0f9a49..cf229903f870 100644 --- a/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts +++ b/src/agents/pi-embedded-subscribe.handlers.lifecycle.ts @@ -1,5 +1,6 @@ import { emitAgentEvent } from "../infra/agent-events.js"; import { createInlineCodeState } from "../markdown/code-spans.js"; +import { hasAcceptedSessionSpawn } from "./accepted-session-spawn.js"; import { buildApiErrorObservationFields, buildTextObservationFields, @@ -48,6 +49,7 @@ export function handleAgentEnd(ctx: EmbeddedPiSubscribeContext): void | Promise< const hadDeterministicSideEffect = ctx.state.hadDeterministicSideEffect === true || hasCommittedMessagingToolDeliveryEvidence(ctx.state) || + hasAcceptedSessionSpawn(ctx.state.acceptedSessionSpawns) || (ctx.state.successfulCronAdds ?? 0) > 0; const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({ hasAssistantVisibleText, diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts index 9c9370b21786..aaba0fba7bcf 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.test.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.test.ts @@ -47,6 +47,7 @@ function createTestContext(): { state: { toolMetaById: new Map(), toolMetas: [], + acceptedSessionSpawns: [], toolSummaryById: new Set(), itemActiveIds: new Set(), itemStartedCount: 0, @@ -280,6 +281,79 @@ describe("handleToolExecutionEnd cron.add commitment tracking", () => { }); }); +describe("handleToolExecutionEnd sessions_spawn terminal success tracking", () => { + it("records accepted sessions_spawn identifiers", async () => { + const { ctx } = createTestContext(); + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "sessions_spawn", + toolCallId: "tool-spawn-accepted", + isError: false, + result: { + details: { + status: "accepted", + runId: " run-child ", + childSessionKey: " agent:claude:subagent:child ", + }, + }, + } as never, + ); + + expect(ctx.state.acceptedSessionSpawns).toEqual([ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ]); + expect(ctx.state.replayState).toEqual({ + replayInvalid: true, + hadPotentialSideEffects: true, + }); + }); + + it("does not record failed or malformed sessions_spawn results", async () => { + const { ctx } = createTestContext(); + + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "sessions_spawn", + toolCallId: "tool-spawn-failed", + isError: false, + result: { + details: { + status: "error", + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + }, + } as never, + ); + await handleToolExecutionEnd( + ctx as never, + { + type: "tool_execution_end", + toolName: "sessions_spawn", + toolCallId: "tool-spawn-malformed", + isError: false, + result: { + details: { + status: "accepted", + runId: "run-child", + childSessionKey: " ", + }, + }, + } as never, + ); + + expect(ctx.state.acceptedSessionSpawns).toEqual([]); + }); +}); + describe("handleToolExecutionEnd mutating failure recovery", () => { it("marks middleware failures on the last tool error", async () => { const { ctx } = createTestContext(); diff --git a/src/agents/pi-embedded-subscribe.handlers.tools.ts b/src/agents/pi-embedded-subscribe.handlers.tools.ts index 787ed877aa94..7ac67948d4f9 100644 --- a/src/agents/pi-embedded-subscribe.handlers.tools.ts +++ b/src/agents/pi-embedded-subscribe.handlers.tools.ts @@ -21,6 +21,7 @@ import type { PluginHookAfterToolCallEvent } from "../plugins/types.js"; import { createLazyImportLoader } from "../shared/lazy-promise.js"; import { normalizeOptionalLowercaseString, readStringValue } from "../shared/string-coerce.js"; import { truncateUtf16Safe } from "../utils.js"; +import { normalizeAcceptedSessionSpawnResult } from "./accepted-session-spawn.js"; import type { ApplyPatchSummary } from "./apply-patch.js"; import type { ExecToolDetails } from "./bash-tools.exec-types.js"; import { parseExecApprovalResultText } from "./exec-approval-result.js"; @@ -944,6 +945,13 @@ export async function handleToolExecutionEnd( const completedMutatingAction = !isToolError && Boolean(callSummary?.mutatingAction); const meta = callSummary?.meta; ctx.state.toolMetas.push({ toolName, meta }); + const acceptedSessionSpawn = + toolName === "sessions_spawn" && !isToolError + ? normalizeAcceptedSessionSpawnResult(sanitizedResult) + : null; + if (acceptedSessionSpawn) { + ctx.state.acceptedSessionSpawns.push(acceptedSessionSpawn); + } ctx.state.toolMetaById.delete(toolCallId); ctx.state.toolSummaryById.delete(toolCallId); if (isToolError) { @@ -977,7 +985,7 @@ export async function handleToolExecutionEnd( ctx.state.lastToolError = undefined; } } - if (completedMutatingAction) { + if (completedMutatingAction || acceptedSessionSpawn) { ctx.state.replayState = mergeEmbeddedRunReplayState(ctx.state.replayState, { replayInvalid: true, hadPotentialSideEffects: true, diff --git a/src/agents/pi-embedded-subscribe.handlers.types.ts b/src/agents/pi-embedded-subscribe.handlers.types.ts index 33c481652b0d..8e24c03528c0 100644 --- a/src/agents/pi-embedded-subscribe.handlers.types.ts +++ b/src/agents/pi-embedded-subscribe.handlers.types.ts @@ -5,6 +5,7 @@ import type { ReplyDirectiveParseResult } from "../auto-reply/reply/reply-direct import type { ReasoningLevel } from "../auto-reply/thinking.js"; import type { InlineCodeState } from "../markdown/code-spans.js"; import type { HookRunner } from "../plugins/hooks.js"; +import type { AcceptedSessionSpawn } from "./accepted-session-spawn.js"; import type { EmbeddedBlockChunker } from "./pi-embedded-block-chunker.js"; import type { MessagingToolSend } from "./pi-embedded-messaging.types.js"; import type { BlockReplyPayload } from "./pi-embedded-payloads.js"; @@ -33,6 +34,7 @@ export type ToolCallSummary = { export type EmbeddedPiSubscribeState = { assistantTexts: string[]; toolMetas: Array<{ toolName?: string; meta?: string }>; + acceptedSessionSpawns: AcceptedSessionSpawn[]; toolMetaById: Map; toolSummaryById: Set; execLiveUpdateStateById?: Map; @@ -201,6 +203,7 @@ type ToolHandlerState = Pick< EmbeddedPiSubscribeState, | "toolMetaById" | "toolMetas" + | "acceptedSessionSpawns" | "toolSummaryById" | "execLiveUpdateStateById" | "itemActiveIds" diff --git a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts index 4096eb6b3173..32967cd8c360 100644 --- a/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts +++ b/src/agents/pi-embedded-subscribe.subscribe-embedded-pi-session.subscribeembeddedpisession.test.ts @@ -1175,4 +1175,47 @@ describe("subscribeEmbeddedPiSession", () => { replayInvalid: true, }); }); + + it("preserves accepted session spawn terminal evidence across compaction retries", () => { + const { session, emit } = createStubSessionHarness(); + const onAgentEvent = vi.fn(); + const subscription = subscribeEmbeddedPiSession({ + session, + runId: "run-spawn-side-effect-compaction", + onAgentEvent, + sessionKey: "test-session", + }); + + emitToolRun({ + emit, + toolName: "sessions_spawn", + toolCallId: "spawn-1", + args: { prompt: "continue in a child session" }, + isError: false, + result: { + details: { + status: "accepted", + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + }, + }); + emit({ type: "compaction_end", willRetry: true, result: { summary: "compacted" } }); + + expect(subscription.getAcceptedSessionSpawns()).toEqual([ + { + runId: "run-child", + childSessionKey: "agent:claude:subagent:child", + }, + ]); + + emit({ type: "agent_end" }); + + const payloads = extractAgentEventPayloads(onAgentEvent.mock.calls); + expectLifecyclePayload(payloads, { + phase: "end", + livenessState: "working", + replayInvalid: true, + }); + }); }); diff --git a/src/agents/pi-embedded-subscribe.ts b/src/agents/pi-embedded-subscribe.ts index 4ef6ab49bc2f..bb837c8d7708 100644 --- a/src/agents/pi-embedded-subscribe.ts +++ b/src/agents/pi-embedded-subscribe.ts @@ -131,6 +131,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar const state: EmbeddedPiSubscribeState = { assistantTexts: [], toolMetas: [], + acceptedSessionSpawns: [], toolMetaById: new Map(), toolSummaryById: new Set(), itemActiveIds: new Set(), @@ -948,6 +949,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar messagingToolSentTargets, }) || state.successfulCronAdds > 0 || + state.acceptedSessionSpawns.length > 0 || state.visibleBlockReplyCount > 0; assistantTexts.length = 0; toolMetas.length = 0; @@ -1061,6 +1063,7 @@ export function subscribeEmbeddedPiSession(params: SubscribeEmbeddedPiSessionPar return { assistantTexts, toolMetas, + getAcceptedSessionSpawns: () => state.acceptedSessionSpawns.slice(), runToolLifecycle: async (toolParams: { toolName: string; toolCallId: string; diff --git a/src/agents/pi-tool-handler-state.test-helpers.ts b/src/agents/pi-tool-handler-state.test-helpers.ts index 12fe731df33b..35e04e951147 100644 --- a/src/agents/pi-tool-handler-state.test-helpers.ts +++ b/src/agents/pi-tool-handler-state.test-helpers.ts @@ -5,6 +5,7 @@ export function createBaseToolHandlerState() { replayState: createEmbeddedRunReplayState(), toolMetaById: new Map(), toolMetas: [] as Array<{ toolName?: string; meta?: string }>, + acceptedSessionSpawns: [], toolSummaryById: new Set(), itemActiveIds: new Set(), itemStartedCount: 0,