mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
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 head0f6d92b8cd. - Required merge gates passed before the squash merge. Prepared head SHA:0f6d92b8cdReview: 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:
@@ -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.
|
||||
|
||||
@@ -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",
|
||||
];
|
||||
|
||||
37
src/agents/accepted-session-spawn.ts
Normal file
37
src/agents/accepted-session-spawn.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -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)
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 ||
|
||||
|
||||
@@ -100,6 +100,7 @@ export function createSubscriptionMock(): SubscriptionMock {
|
||||
unsubscribe: () => {},
|
||||
setTerminalLifecycleMeta: () => {},
|
||||
waitForCompactionRetry: async () => {},
|
||||
getAcceptedSessionSpawns: () => [],
|
||||
getMessagingToolSentTexts: () => [] as string[],
|
||||
getMessagingToolSentMediaUrls: () => [] as string[],
|
||||
getMessagingToolSentTargets: () => [] as MessagingToolSend[],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user