Skip to content

Commit 4bc5e2a

Browse files
author
hclsys
committed
fix(agents): preserve final text after stale incomplete-turn state
Fixes #80918
1 parent 04a868b commit 4bc5e2a

3 files changed

Lines changed: 91 additions & 5 deletions

File tree

src/agents/pi-embedded-runner/run.incomplete-turn.test.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { hasCommittedMessagingToolDeliveryEvidence } from "./delivery-evidence.j
44
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
55
import {
66
loadRunOverflowCompactionHarness,
7+
mockedBuildEmbeddedRunPayloads,
78
mockedClassifyFailoverReason,
89
mockedGlobalHookRunner,
910
mockedLog,
@@ -1181,6 +1182,37 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
11811182
expect(incompleteTurnText).toBeNull();
11821183
});
11831184

1185+
it("uses current attempt assistant state instead of stale lastAssistant for incomplete-turn detection (#80918)", () => {
1186+
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1187+
payloadCount: 1,
1188+
aborted: false,
1189+
timedOut: false,
1190+
attempt: makeAttemptResult({
1191+
assistantTexts: ["Compression complete and verified. Here is the summary."],
1192+
toolMetas: [{ toolName: "update_plan" }],
1193+
lastAssistant: {
1194+
role: "assistant",
1195+
stopReason: "toolUse",
1196+
provider: "openrouter",
1197+
model: "deepseek/deepseek-v4-pro",
1198+
content: [{ type: "tool_use", id: "tool_1", name: "update_plan", input: {} }],
1199+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1200+
currentAttemptAssistant: {
1201+
role: "assistant",
1202+
stopReason: "stop",
1203+
provider: "openrouter",
1204+
model: "deepseek/deepseek-v4-pro",
1205+
content: [
1206+
{ type: "thinking", thinking: "update_plan returned successfully" },
1207+
{ type: "text", text: "Compression complete and verified. Here is the summary." },
1208+
],
1209+
} as unknown as EmbeddedRunAttemptResult["currentAttemptAssistant"],
1210+
}),
1211+
});
1212+
1213+
expect(incompleteTurnText).toBeNull();
1214+
});
1215+
11841216
it("surfaces an error for tool-use terminal turn with pre-tool text via runEmbeddedPiAgent (#76477)", async () => {
11851217
mockedClassifyFailoverReason.mockReturnValue(null);
11861218
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
@@ -1212,6 +1244,56 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => {
12121244
expectWarnMessageWith("incomplete turn detected");
12131245
});
12141246

1247+
it("preserves final assistant text when lastAssistant is stale after update_plan (#80918)", async () => {
1248+
mockedClassifyFailoverReason.mockReturnValue(null);
1249+
mockedBuildEmbeddedRunPayloads.mockReturnValueOnce([
1250+
{ text: "Compression complete and verified. Here is the summary." },
1251+
]);
1252+
const currentAttemptAssistant = {
1253+
role: "assistant",
1254+
stopReason: "stop",
1255+
provider: "openrouter",
1256+
model: "deepseek/deepseek-v4-pro",
1257+
content: [
1258+
{ type: "thinking", thinking: "update_plan returned successfully" },
1259+
{ type: "text", text: "Compression complete and verified. Here is the summary." },
1260+
],
1261+
} as unknown as EmbeddedRunAttemptResult["currentAttemptAssistant"];
1262+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
1263+
makeAttemptResult({
1264+
assistantTexts: ["Compression complete and verified. Here is the summary."],
1265+
toolMetas: [{ toolName: "update_plan" }],
1266+
lastAssistant: {
1267+
stopReason: "toolUse",
1268+
provider: "openrouter",
1269+
model: "deepseek/deepseek-v4-pro",
1270+
content: [{ type: "tool_use", id: "tool_1", name: "update_plan", input: {} }],
1271+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1272+
currentAttemptAssistant,
1273+
}),
1274+
);
1275+
1276+
const result = await runEmbeddedPiAgent({
1277+
...overflowBaseRunParams,
1278+
provider: "openrouter",
1279+
model: "deepseek/deepseek-v4-pro",
1280+
runId: "run-update-plan-stale-last-assistant",
1281+
});
1282+
1283+
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
1284+
expect(mockedBuildEmbeddedRunPayloads).toHaveBeenCalledWith(
1285+
expect.objectContaining({ lastAssistant: currentAttemptAssistant }),
1286+
);
1287+
expect(result.payloads?.[0]).toMatchObject({
1288+
text: "Compression complete and verified. Here is the summary.",
1289+
});
1290+
expect(result.payloads?.[0]?.isError).not.toBe(true);
1291+
expectNoWarnMessageWith("incomplete turn detected");
1292+
expect(result.meta?.finalAssistantVisibleText).toBe(
1293+
"Compression complete and verified. Here is the summary.",
1294+
);
1295+
});
1296+
12151297
it("treats missing replay metadata as replay-invalid", () => {
12161298
const attempt = makeAttemptResult();
12171299
delete (attempt as Partial<EmbeddedRunAttemptResult>).replayMetadata;

src/agents/pi-embedded-runner/run.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2470,13 +2470,16 @@ export async function runEmbeddedPiAgent(
24702470
compactionCount: autoCompactionCount > 0 ? autoCompactionCount : undefined,
24712471
compactionTokensAfter: lastCompactionTokensAfter,
24722472
};
2473-
const finalAssistantVisibleText = resolveFinalAssistantVisibleText(sessionLastAssistant);
2474-
const finalAssistantRawText = resolveFinalAssistantRawText(sessionLastAssistant);
2473+
const terminalAssistantForPayloads = currentAttemptAssistant ?? sessionLastAssistant;
2474+
const finalAssistantVisibleText = resolveFinalAssistantVisibleText(
2475+
terminalAssistantForPayloads,
2476+
);
2477+
const finalAssistantRawText = resolveFinalAssistantRawText(terminalAssistantForPayloads);
24752478

24762479
const payloads = buildEmbeddedRunPayloads({
24772480
assistantTexts: attempt.assistantTexts,
24782481
toolMetas: attempt.toolMetas,
2479-
lastAssistant: attempt.lastAssistant,
2482+
lastAssistant: terminalAssistantForPayloads,
24802483
lastToolError: attempt.lastToolError,
24812484
config: params.config,
24822485
isCronTrigger: params.trigger === "cron",

src/agents/pi-embedded-runner/run/incomplete-turn.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -231,7 +231,8 @@ export function resolveIncompleteTurnPayloadText(params: {
231231
// Pre-tool text alone (payloadCount > 0) must not suppress the incomplete-
232232
// turn check in that case — the final post-tool response was never
233233
// produced. (#76477)
234-
const toolUseTerminal = params.attempt.lastAssistant?.stopReason === "toolUse";
234+
const terminalAssistant = params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant;
235+
const toolUseTerminal = terminalAssistant?.stopReason === "toolUse";
235236

236237
if (
237238
(params.payloadCount !== 0 && !toolUseTerminal) ||
@@ -256,7 +257,7 @@ export function resolveIncompleteTurnPayloadText(params: {
256257
const stopReason = params.attempt.lastAssistant?.stopReason;
257258
const incompleteTerminalAssistant = isIncompleteTerminalAssistantTurn({
258259
hasAssistantVisibleText: params.payloadCount > 0,
259-
lastAssistant: params.attempt.lastAssistant,
260+
lastAssistant: terminalAssistant,
260261
});
261262
const reasoningOnlyAssistant = isReasoningOnlyAssistantTurn(
262263
params.attempt.currentAttemptAssistant ?? params.attempt.lastAssistant,

0 commit comments

Comments
 (0)