fix(agents): preserve accepted spawn terminal success (#85135)

Summary:
- The branch adds accepted `sessions_spawn` tracking through embedded Pi subscribe, runner, fallback, replay, lifecycle, tests, deadcode allowlist, and changelog surfaces.
- Reproducibility: yes. at source level. Current main documents accepted `sessions_spawn` results but the pre- ...  and classifier paths do not carry that accepted child-run fact into incomplete-turn or fallback decisions.

Automerge notes:
- PR branch already contained follow-up commit before automerge: test(qa-lab): allow codex fixtures in deadcode
- PR branch already contained follow-up commit before automerge: fix(agents): preserve accepted spawn terminal success

Validation:
- ClawSweeper review passed for head 0f6d92b8cd.
- Required merge gates passed before the squash merge.

Prepared head SHA: 0f6d92b8cd
Review: https://github.com/openclaw/openclaw/pull/85135#issuecomment-4513861326

Co-authored-by: samzong <samzong.lu@gmail.com>
Co-authored-by: clawsweeper <274271284+clawsweeper[bot]@users.noreply.github.com>
Co-authored-by: clawsweeper[bot] <274271284+clawsweeper[bot]@users.noreply.github.com>
Approved-by: takhoffman
Co-authored-by: takhoffman <781889+takhoffman@users.noreply.github.com>
This commit is contained in:
clawsweeper[bot]
2026-05-22 01:16:41 +00:00
committed by GitHub
parent 221f5349b5
commit 7f4bd454fe
23 changed files with 454 additions and 11 deletions

View File

@@ -24,6 +24,7 @@ Docs: https://docs.openclaw.ai
### Fixes
- Agents/subagents: surface blocked child-run completions as errors instead of successful subagent finishes. (#80886) Thanks @TurboTheTurtle.
- Agents/Pi: treat accepted embedded `sessions_spawn` child-session handoffs as terminal progress so parent turns no longer report false non-deliverable failures. (#85054) Thanks @samzong.
- WhatsApp: update Baileys to `7.0.0-rc13` and drop the obsolete logger type patch.
- Install/update: reject OpenClaw GitHub source package targets early and point moving-main users at the dev/git install path instead of the broken npm source-install flow.
- Gateway: mirror successful same-source message-tool sends into session transcripts so delivered replies stay in later history/context. (#84837) Thanks @iFiras-Max1.

View File

@@ -37,4 +37,8 @@ export const KNIP_UNUSED_FILE_ALLOWLIST = [
// Knip can disagree across supported local/CI platforms for files that are
// only reachable through test-only import graphs. Ignore these when reported,
// but do not require them to be reported.
export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = ["src/gateway/test/server-sessions-helpers.ts"];
export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
"extensions/qa-lab/src/auth-profile-fixture.ts",
"extensions/qa-lab/src/codex-plugin-fixture.ts",
"src/gateway/test/server-sessions-helpers.ts",
];

View File

@@ -0,0 +1,37 @@
import { normalizeOptionalString } from "../shared/string-coerce.js";
export type AcceptedSessionSpawn = {
runId: string;
childSessionKey: string;
};
function asRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: 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 unknown[]): boolean {
return (acceptedSessionSpawns ?? []).some((spawn) => {
const record = asRecord(spawn);
if (!record) {
return false;
}
return Boolean(
normalizeOptionalString(record.runId) && normalizeOptionalString(record.childSessionKey),
);
});
}

View File

@@ -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)
);

View File

@@ -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();
});
});

View File

@@ -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<EmbeddedRunAttemptResult> & {
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<EmbeddedRunAttemptResult> & {
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,40 @@ 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("ignores malformed accepted sessions_spawn delivery evidence", () => {
expect(
hasOutboundDeliveryEvidence({
acceptedSessionSpawns: [
null,
{
runId: "run-child",
childSessionKey: " ",
},
],
}),
).toBe(false);
});
it("leaves committed delivery plus tool errors to the tool-error payload path", () => {
const incompleteTurnText = resolveIncompleteTurnPayloadText({
payloadCount: 0,

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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(

View File

@@ -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<unknown>;
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 ||

View File

@@ -100,6 +100,7 @@ export function createSubscriptionMock(): SubscriptionMock {
unsubscribe: () => {},
setTerminalLifecycleMeta: () => {},
waitForCompactionRetry: async () => {},
getAcceptedSessionSpawns: () => [],
getMessagingToolSentTexts: () => [] as string[],
getMessagingToolSentMediaUrls: () => [] as string[],
getMessagingToolSentTargets: () => [] as MessagingToolSend[],

View File

@@ -3191,9 +3191,8 @@ export async function runEmbeddedAttempt(
prompt: string,
options?: Parameters<typeof activeSession.prompt>[1],
): Promise<void> =>
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,

View File

@@ -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<Pick<EmbeddedRunAttemptResult, "messagingToolSentTargets">>;
Partial<Pick<EmbeddedRunAttemptResult, "messagingToolSentTargets" | "acceptedSessionSpawns">>;
type IncompleteTurnAttempt = Pick<
EmbeddedRunAttemptResult,
@@ -47,7 +48,8 @@ type IncompleteTurnAttempt = Pick<
| "replayMetadata"
| "promptErrorSource"
| "timedOutDuringCompaction"
>;
> &
Partial<Pick<EmbeddedRunAttemptResult, "acceptedSessionSpawns">>;
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,
);
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -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"];

View File

@@ -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,

View File

@@ -47,6 +47,7 @@ function createTestContext(): {
state: {
toolMetaById: new Map<string, ToolCallSummary>(),
toolMetas: [],
acceptedSessionSpawns: [],
toolSummaryById: new Set<string>(),
itemActiveIds: new Set<string>(),
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();

View File

@@ -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,

View File

@@ -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<string, ToolCallSummary>;
toolSummaryById: Set<string>;
execLiveUpdateStateById?: Map<string, { lastEmittedAtMs: number }>;
@@ -201,6 +203,7 @@ type ToolHandlerState = Pick<
EmbeddedPiSubscribeState,
| "toolMetaById"
| "toolMetas"
| "acceptedSessionSpawns"
| "toolSummaryById"
| "execLiveUpdateStateById"
| "itemActiveIds"

View File

@@ -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,
});
});
});

View File

@@ -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 <T>(toolParams: {
toolName: string;
toolCallId: string;

View File

@@ -5,6 +5,7 @@ export function createBaseToolHandlerState() {
replayState: createEmbeddedRunReplayState(),
toolMetaById: new Map<string, unknown>(),
toolMetas: [] as Array<{ toolName?: string; meta?: string }>,
acceptedSessionSpawns: [],
toolSummaryById: new Set<string>(),
itemActiveIds: new Set<string>(),
itemStartedCount: 0,