fix(agents): prevent stale replies after transcript rewrites (#109676)

* fix(agents): scope final replies to current run

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>

* refactor(agents): internalize attempt helper

* fix(agents): preserve yielded turn classification

* fix(agents): preserve yielded run ownership

---------

Co-authored-by: ZengWen-DT <ceng.wen@xydigit.com>
This commit is contained in:
Peter Steinberger
2026-07-16 23:58:43 -07:00
committed by GitHub
parent a33ddc9dc5
commit 44569ffdda
19 changed files with 183 additions and 21 deletions
+2 -2
View File
@@ -388,6 +388,7 @@ export async function runPreparedEmbeddedLoop(
sessionIdUsed,
sessionFileUsed,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
attemptAssistant,
terminalOutcome,
terminalAborted,
@@ -516,8 +517,7 @@ export async function runPreparedEmbeddedLoop(
} = prepareEmbeddedRunTerminal({
runParams: params,
attempt,
attemptAssistant,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
provider,
model: model.id,
activeErrorContext,
@@ -789,6 +789,87 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
expect(result.meta.finalAssistantVisibleText).toBeUndefined();
});
it("does not resolve a successful run from a stale transcript assistant", async () => {
const staleAssistant = {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "text", text: "Prior transcript reply." }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
const completedAssistant = {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "text", text: "Current run reply." }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"]>;
mockedBuildEmbeddedRunPayloads.mockReturnValue([{ text: "Current run reply." }]);
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: ["Current run reply."],
lastAssistant: staleAssistant,
currentAttemptAssistant: staleAssistant,
currentAttemptCompletedAssistant: completedAssistant,
}),
);
const result = await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-success-stale-transcript-assistant",
});
expect(result.payloads).toEqual([{ text: "Current run reply." }]);
expect(result.meta.finalAssistantVisibleText).toBe("Current run reply.");
expect(result.meta.finalAssistantRawText).toBe("Current run reply.");
expect(mockedBuildEmbeddedRunPayloads).toHaveBeenCalledWith(
expect.objectContaining({
currentAssistant: completedAssistant,
lastAssistant: completedAssistant,
}),
);
});
it("retains the yielded attempt assistant for paused-turn payload classification", async () => {
const completedAssistant = {
role: "assistant",
stopReason: "stop",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "text", text: "Earlier completed cycle." }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"]>;
const yieldedAssistant = {
role: "assistant",
stopReason: "aborted",
provider: "openai",
model: "gpt-5.5",
content: [{ type: "toolCall", name: "sessions_yield", arguments: {} }],
} as unknown as NonNullable<EmbeddedRunAttemptResult["lastAssistant"]>;
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
makeAttemptResult({
assistantTexts: [],
lastAssistant: yieldedAssistant,
currentAttemptAssistant: undefined,
currentAttemptCompletedAssistant: completedAssistant,
yieldDetected: true,
}),
);
const result = await runEmbeddedAgent({
...overflowBaseRunParams,
provider: "openai",
model: "gpt-5.5",
runId: "run-yielded-assistant-classification",
});
expect(result.meta).toMatchObject({ livenessState: "paused", yielded: true });
expect(mockedBuildEmbeddedRunPayloads).toHaveBeenCalledWith(
expect.objectContaining({ currentAssistant: null, lastAssistant: yieldedAssistant }),
);
});
it("recovers a completed prompt-timeout assistant without collected assistant text", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
const finalText = "Completed answer after the timeout race.";
@@ -2523,7 +2604,10 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
stopReason: "stop",
content: [{ type: "text", text: finalText }],
}),
lastAssistant: expect.objectContaining({ stopReason: "toolUse" }),
lastAssistant: expect.objectContaining({
stopReason: "stop",
content: [{ type: "text", text: finalText }],
}),
}),
);
expect(result.meta.finalAssistantVisibleText).toBe(finalText);
@@ -64,6 +64,8 @@ export function makeAttemptResult(
assistantTexts: ["Hello!"],
acceptedSessionSpawns,
lastAssistant: undefined,
currentAttemptCompletedAssistant:
overrides.currentAttemptCompletedAssistant ?? overrides.currentAttemptAssistant,
messagesSnapshot: [],
replayMetadata:
overrides.replayMetadata ??
@@ -133,6 +133,7 @@ export async function runEmbeddedAttemptSettledPhase(
let promptCacheChangesForTurn: PromptCacheChange[] | null = null;
let lastAssistant: AssistantMessage | undefined;
let currentAttemptAssistant: EmbeddedRunAttemptResult["currentAttemptAssistant"];
let currentAttemptCompletedAssistant: EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"];
let attemptUsage: NormalizedUsage | undefined;
let cacheBreak: PromptCacheBreak | null = null;
let contextBudgetStatus: EmbeddedRunAttemptResult["contextBudgetStatus"];
@@ -290,6 +291,7 @@ export async function runEmbeddedAttemptSettledPhase(
sessionIdUsed = settledStream.sessionIdUsed;
lastAssistant = settledStream.lastAssistant;
currentAttemptAssistant = settledStream.currentAttemptAssistant;
currentAttemptCompletedAssistant = settledStream.currentAttemptCompletedAssistant;
attemptUsage = settledStream.attemptUsage;
cacheBreak = settledStream.cacheBreak;
sessionRuntimeState.promptCache = settledStream.promptCache;
@@ -387,6 +389,7 @@ export async function runEmbeddedAttemptSettledPhase(
...(beforeAgentFinalizeRevisionReason ? { beforeAgentFinalizeRevisionReason } : {}),
lastAssistant,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
attemptUsage,
promptCache: sessionRuntimeState.promptCache,
contextBudgetStatus,
@@ -87,6 +87,9 @@ export async function normalizeEmbeddedRunAttempt(input: {
currentAttemptAssistant: ReturnType<
typeof normalizeEmbeddedRunAttemptResult
>["currentAttemptAssistant"];
currentAttemptCompletedAssistant: ReturnType<
typeof normalizeEmbeddedRunAttemptResult
>["currentAttemptCompletedAssistant"];
attemptAssistant: ReturnType<
typeof normalizeEmbeddedRunAttemptResult
>["currentAttemptAssistant"];
@@ -129,6 +132,7 @@ export async function normalizeEmbeddedRunAttempt(input: {
sessionFileUsed,
lastAssistant: sessionLastAssistant,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
} = attempt;
const timedOutDuringToolExecution = attempt.timedOutDuringToolExecution ?? false;
const timedOutByRunBudget = attempt.timedOutByRunBudget ?? false;
@@ -320,6 +324,7 @@ export async function normalizeEmbeddedRunAttempt(input: {
sessionIdUsed,
sessionFileUsed,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
attemptAssistant,
terminalOutcome,
terminalAborted,
@@ -61,6 +61,7 @@ describe("embedded attempt phase lifecycle state", () => {
},
isCompactionInFlight: () => false,
getCompactionCount: () => 0,
getCurrentAttemptAssistant: () => undefined,
getUsageTotals: () => undefined,
} as never,
state: {
@@ -59,6 +59,7 @@ type EmbeddedAttemptResultState = Pick<
| "beforeAgentFinalizeRevisionReason"
| "lastAssistant"
| "currentAttemptAssistant"
| "currentAttemptCompletedAssistant"
| "attemptUsage"
| "promptCache"
| "contextBudgetStatus"
@@ -58,6 +58,7 @@ type StreamSettleResult = {
sessionIdUsed: string;
lastAssistant: EmbeddedRunAttemptResult["lastAssistant"];
currentAttemptAssistant: EmbeddedRunAttemptResult["currentAttemptAssistant"];
currentAttemptCompletedAssistant: EmbeddedRunAttemptResult["currentAttemptCompletedAssistant"];
attemptUsage: EmbeddedRunAttemptResult["attemptUsage"];
cacheBreak: PromptCacheBreak | null;
lastCallUsage: NormalizedUsage | undefined;
@@ -226,6 +227,7 @@ export async function settleEmbeddedAttemptStream(input: {
let messagesSnapshot: AgentMessage[] = [];
let lastAssistant: AssistantMessage | undefined;
let currentAttemptAssistant: AssistantMessage | undefined;
let currentAttemptCompletedAssistant: AssistantMessage | undefined;
let attemptUsage: EmbeddedRunAttemptResult["attemptUsage"];
let cacheBreak: PromptCacheBreak | null = null;
let lastCallUsage: NormalizedUsage | undefined;
@@ -285,6 +287,8 @@ export async function settleEmbeddedAttemptStream(input: {
messagesSnapshot,
prePromptMessageCount: input.prePromptMessageCount,
});
currentAttemptCompletedAssistant = subscription.getCurrentAttemptAssistant();
const usageAssistant = currentAttemptCompletedAssistant ?? currentAttemptAssistant;
attemptUsage = subscription.getUsageTotals();
cacheBreak = input.cache.observabilityEnabled
? completePromptCacheObservation({
@@ -294,7 +298,7 @@ export async function settleEmbeddedAttemptStream(input: {
usage: attemptUsage,
})
: null;
lastCallUsage = normalizeUsage(currentAttemptAssistant?.usage);
lastCallUsage = normalizeUsage(usageAssistant?.usage);
const promptCacheObservation =
input.cache.observabilityEnabled &&
(cacheBreak || input.cache.changesForTurn || typeof attemptUsage?.cacheRead === "number")
@@ -321,7 +325,7 @@ export async function settleEmbeddedAttemptStream(input: {
observation: promptCacheObservation,
lastCacheTouchAt: resolvePromptCacheTouchTimestamp({
lastCallUsage,
assistantTimestamp: currentAttemptAssistant?.timestamp,
assistantTimestamp: usageAssistant?.timestamp,
fallbackLastCacheTouchAt,
}),
});
@@ -356,6 +360,7 @@ export async function settleEmbeddedAttemptStream(input: {
sessionIdUsed,
lastAssistant,
currentAttemptAssistant,
currentAttemptCompletedAssistant,
attemptUsage,
cacheBreak,
lastCallUsage,
@@ -29,7 +29,6 @@ import {
buildLoopPromptCacheInfo,
assembleAttemptContextEngine,
buildContextEnginePromptCacheInfo,
findCurrentAttemptAssistantMessage,
finalizeAttemptContextEngineTurn,
resolvePromptCacheTouchTimestamp,
runAttemptContextEngineBootstrap,
@@ -3190,16 +3189,12 @@ describe("runEmbeddedAttempt context engine sessionKey forwarding", () => {
total: 1340,
},
} as unknown as AgentMessage;
const currentAttemptAssistant = findCurrentAttemptAssistantMessage({
const promptCache = buildLoopPromptCacheInfo({
messagesSnapshot: [seedMessage, priorAssistant],
prePromptMessageCount: 2,
});
const promptCache = buildContextEnginePromptCacheInfo({
retention: "short",
lastCallUsage: (currentAttemptAssistant as { usage?: undefined } | undefined)?.usage,
});
expect(currentAttemptAssistant).toBeUndefined();
expect(promptCache).toEqual({ retention: "short" });
});
@@ -122,6 +122,7 @@ function createSubscriptionMock(): SubscriptionMock {
// override only the lifecycle method they need.
return {
assistantTexts: [] as string[],
getCurrentAttemptAssistant: () => undefined,
getLastAssistantTextMessageIndex: () => undefined,
toolMetas: [] as Array<{ toolName: string; meta?: string; asyncStarted?: boolean }>,
runToolLifecycle: async <T>(toolParams: { execute: () => Promise<T> }) =>
@@ -21,8 +21,7 @@ import type { EmbeddedRunAttemptResult } from "./types.js";
export function prepareEmbeddedRunTerminal(input: {
runParams: RunEmbeddedAgentParams;
attempt: EmbeddedRunAttemptResult;
attemptAssistant?: AssistantMessage;
currentAttemptAssistant?: AssistantMessage;
currentAttemptCompletedAssistant?: AssistantMessage;
provider: string;
model: string;
activeErrorContext: { provider: string; model: string };
@@ -54,12 +53,12 @@ export function prepareEmbeddedRunTerminal(input: {
attemptToolSummary: ReturnType<typeof buildTraceToolSummary>;
failureSignal: ReturnType<typeof resolveEmbeddedRunFailureSignal>;
} {
const { runParams, attempt, attemptAssistant } = input;
const { runParams, attempt } = input;
const timedOutDuringPrompt =
input.terminalTimedOut && !input.timedOutDuringCompaction && !input.timedOutDuringToolExecution;
// A prior same-model assistant can remain in the session snapshot. Timeout
// recovery must project only output owned by the prompt that just timed out.
const terminalAssistant = timedOutDuringPrompt ? input.currentAttemptAssistant : attemptAssistant;
// Session transcript fallbacks can reference an earlier rewritten turn.
// Terminal delivery and metadata must stay scoped to this model attempt.
const terminalAssistant = input.currentAttemptCompletedAssistant;
const usageMeta = buildUsageAgentMetaFields({
usageAccumulator: input.usageAccumulator,
lastAssistantUsage: terminalAssistant?.usage as UsageLike | undefined,
@@ -90,15 +89,25 @@ export function prepareEmbeddedRunTerminal(input: {
: undefined,
compactionTokensAfter: input.contextRecoveryState.lastCompactionTokensAfter,
};
const finalAssistantVisibleText = resolveFinalAssistantVisibleText(terminalAssistant);
const finalAssistantRawText = resolveFinalAssistantRawText(terminalAssistant);
const attemptFinalText = attempt.assistantTexts
.toReversed()
.map((text) => text.trim())
.find((text) => text.length > 0);
const finalAssistantVisibleText =
resolveFinalAssistantVisibleText(terminalAssistant) ?? attemptFinalText;
const finalAssistantRawText = resolveFinalAssistantRawText(terminalAssistant) ?? attemptFinalText;
// A yielded attempt ends before message_end. Its aborted tool-call assistant,
// not an earlier completed cycle, owns paused-turn classification.
const payloadAssistant = attempt.yieldDetected
? attempt.lastAssistant
: input.currentAttemptCompletedAssistant;
const payloads = buildEmbeddedRunPayloads({
assistantTexts: attempt.assistantTexts,
assistantMessageIndex: attempt.lastAssistantTextMessageIndex,
assistantTranscriptOwned: attempt.assistantTranscriptOwned,
toolMetas: attempt.toolMetas,
lastAssistant: timedOutDuringPrompt ? input.currentAttemptAssistant : attempt.lastAssistant,
currentAssistant: input.currentAttemptAssistant ?? null,
lastAssistant: payloadAssistant,
currentAssistant: attempt.yieldDetected ? null : (payloadAssistant ?? null),
lastToolError: attempt.lastToolError,
config: runParams.config,
isCronTrigger: runParams.trigger === "cron",
@@ -272,6 +272,8 @@ export type EmbeddedRunAttemptResult = {
acceptedSessionSpawns?: AcceptedSessionSpawn[];
lastAssistant: AssistantMessage | undefined;
currentAttemptAssistant?: AssistantMessage | undefined;
/** Completed message_end snapshot owned by this model attempt. */
currentAttemptCompletedAssistant?: AssistantMessage | undefined;
lastToolError?: ToolErrorSummary;
didSendViaMessagingTool: boolean;
didDeliverSourceReplyViaMessageTool?: boolean;
@@ -74,6 +74,7 @@ function createMessageUpdateContext(
},
log: { debug: params.debug ?? vi.fn() },
noteLastAssistant: vi.fn(),
noteCompletedAssistant: vi.fn(),
stripBlockTags: params.stripBlockTags ?? vi.fn((text: string) => text),
consumePartialReplyDirectives:
params.consumePartialReplyDirectives ??
@@ -160,6 +161,7 @@ function createMessageEndContext(
...params.state,
},
noteLastAssistant: vi.fn(),
noteCompletedAssistant: vi.fn(),
recordAssistantUsage: vi.fn(),
commitAssistantUsage: vi.fn(),
log: { debug: vi.fn(), info: vi.fn(), warn: params.warn ?? vi.fn() },
@@ -1159,6 +1159,7 @@ export function handleMessageEnd(
const suppressDeterministicApprovalOutput = shouldSuppressDeterministicApprovalOutput(ctx.state);
const suppressMessageToolOnlySourceReplyOutput = hasMessageToolOnlySourceDelivery(ctx);
ctx.noteLastAssistant(assistantMessage);
ctx.noteCompletedAssistant(assistantMessage);
ctx.recordAssistantUsage((assistantMessage as { usage?: unknown }).usage);
ctx.commitAssistantUsage();
if (suppressVisibleAssistantOutput) {
@@ -61,6 +61,7 @@ function createMockContext(overrides?: {
// Fill in remaining required fields with no-ops.
blockChunker: null,
noteLastAssistant: vi.fn(),
noteCompletedAssistant: vi.fn(),
stripBlockTags: vi.fn((t: string) => t),
emitBlockChunk: vi.fn(),
flushBlockReplyBuffer: vi.fn(),
@@ -196,6 +196,7 @@ export type EmbeddedAgentSubscribeContext = {
builtinToolNames?: ReadonlySet<string>;
trustedLocalMediaToolNames?: ReadonlySet<string>;
noteLastAssistant: (msg: AgentMessage) => void;
noteCompletedAssistant: (msg: AgentMessage) => void;
shouldEmitToolResult: () => boolean;
shouldEmitToolOutput: () => boolean;
@@ -41,6 +41,21 @@ describe("subscribeEmbeddedAgentSession", () => {
expect(subscription.assistantTexts).toEqual(["Hello world"]);
});
it("keeps the completed assistant independent from transcript mutation", () => {
const { session, emit } = createStubSessionHarness();
const subscription = subscribeEmbeddedAgentSession({ session, runId: "run" });
const assistantMessage = {
role: "assistant",
content: [{ type: "text", text: "Current run reply" }],
} as AssistantMessage;
emit({ type: "message_end", message: assistantMessage });
assistantMessage.content = [{ type: "text", text: "Rewritten transcript reply" }];
expect(subscription.getCurrentAttemptAssistant()?.content).toEqual([
{ type: "text", text: "Current run reply" },
]);
});
it("does not duplicate assistantTexts when message_end repeats with trailing whitespace changes", () => {
const { session, emit } = createStubSessionHarness();
@@ -1,5 +1,6 @@
// Compaction retry subscription tests cover retry wait accounting, compaction
// event emission, abort-on-unsubscribe, and verbose tool summary behavior.
import type { AssistantMessage } from "openclaw/plugin-sdk/llm";
import { describe, expect, it, vi } from "vitest";
import { onAgentEvent } from "../infra/agent-events.js";
import { createSubscribedSessionHarness } from "./embedded-agent-subscribe.e2e-harness.js";
@@ -75,6 +76,24 @@ describe("subscribeEmbeddedAgentSession", () => {
expect(subscription.getLastCompactionTokensAfter()).toBe(6_789);
});
it("clears the completed assistant when compaction schedules a retry", () => {
const { emit, subscription } = createSubscribedSessionHarness({
runId: "run-compaction-assistant",
});
const assistant = {
role: "assistant",
content: [{ type: "text", text: "Reply before compaction" }],
} as AssistantMessage;
emit({ type: "message_end", message: assistant });
expect(subscription.getCurrentAttemptAssistant()).toEqual(assistant);
expect(subscription.assistantTexts).toEqual(["Reply before compaction"]);
emit({ type: "compaction_end", willRetry: true });
expect(subscription.getCurrentAttemptAssistant()).toBeUndefined();
expect(subscription.assistantTexts).toEqual([]);
});
it("does not count compaction when result is absent", () => {
const { emit, subscription } = createSubscribedSessionHarness({
runId: "run-compaction-no-result",
+15
View File
@@ -14,6 +14,7 @@ import { createStreamingDirectiveAccumulator } from "../auto-reply/reply/streami
import { isSilentReplyText, SILENT_REPLY_TOKEN } from "../auto-reply/tokens.js";
import { formatToolAggregate } from "../auto-reply/tool-meta.js";
import { emitAgentEvent } from "../infra/agent-events.js";
import type { AssistantMessage } from "../llm/types.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { findFinalTagMatches } from "../shared/text/final-tags.js";
import { hasOrphanReasoningCloseBoundary } from "../shared/text/reasoning-tags.js";
@@ -254,6 +255,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
total: 0,
};
let compactionCount = 0;
let currentAttemptAssistant: AssistantMessage | undefined;
const assistantTexts = state.assistantTexts;
const toolMetas = state.toolMetas;
@@ -1280,6 +1282,9 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
state.deterministicApprovalPromptSent = false;
state.lastDeliveredBlockReplyText = undefined;
state.toolExecutionSinceLastBlockReply = false;
// A retry is a new model attempt. A silent retry must not inherit the
// completed assistant from the attempt that triggered compaction.
currentAttemptAssistant = undefined;
state.replayState = mergeEmbeddedRunReplayState(state.replayState, params.initialReplayState);
state.livenessState = "working";
resetAssistantMessageState(0);
@@ -1290,6 +1295,13 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
state.lastAssistant = msg;
}
};
const noteCompletedAssistant = (msg: AgentMessage) => {
if (msg?.role === "assistant") {
// Context-engine projection may later replace or mutate transcript
// objects. Final delivery needs the model event owned by this run.
currentAttemptAssistant = structuredClone(msg) as AssistantMessage;
}
};
const ctx: EmbeddedAgentSubscribeContext = {
params,
@@ -1301,6 +1313,7 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
builtinToolNames: params.builtinToolNames,
trustedLocalMediaToolNames: params.trustedLocalMediaToolNames,
noteLastAssistant,
noteCompletedAssistant,
shouldEmitToolResult,
shouldEmitToolOutput,
emitToolSummary,
@@ -1374,6 +1387,8 @@ export function subscribeEmbeddedAgentSession(params: SubscribeEmbeddedAgentSess
return {
assistantTexts,
getCurrentAttemptAssistant: () =>
currentAttemptAssistant ? structuredClone(currentAttemptAssistant) : undefined,
getLastAssistantTextMessageIndex: () =>
state.lastAssistantTextMessageIndex >= 0 ? state.lastAssistantTextMessageIndex : undefined,
toolMetas,