Skip to content

Commit c1cbc15

Browse files
steipeteLiuwqGit
andauthored
fix(agents): preserve completed post-tool replies (#100655)
* fix(agents): prefer current attempt terminal state Co-authored-by: weiqinl <[email protected]> * test(agents): model current attempt terminal state --------- Co-authored-by: weiqinl <[email protected]>
1 parent 8380667 commit c1cbc15

5 files changed

Lines changed: 185 additions & 73 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai
2929
- **Android network recovery:** reconnect Gateway sessions immediately when Android regains a validated network instead of waiting for the current reconnect backoff. (#100347) Thanks @ly85206559.
3030
- **Android Voice layout:** keep Voice settings controls within their intended width so nested cards do not overflow or clip on constrained screens. (#100491) Thanks @IWhatsskill.
3131
- **Android camera logging:** remove release-path camera clip diagnostics that exposed temporary file details and added noisy invoke logs. (#99484) Thanks @NianJiuZst.
32+
- **Agent final replies:** prefer the current attempt's terminal assistant across incomplete-turn classification and success metadata so a stale pre-tool snapshot cannot replace a completed post-tool answer with an error. (#94637) Thanks @LiuwqGit.
3233
- **Small-context compaction:** cap the effective reserve against the known model context window so small local models do not enter compaction from the first token. (#100621) Thanks @vincentkoc.
3334
- **Plugin install diagnostics:** suppress the misleading hook-pack fallback after plugin install failures only when the hook manifest is absent, while preserving actionable malformed hook-pack errors. (#100554) Thanks @vincentkoc.
3435
- **Config validation diagnostics:** emit each unchanged sanitized validation-warning payload once per config path, reset deduplication after a clean validation, and preserve the warning fingerprint across transient invalid reads and failed refreshes. (#100569, #25574) Thanks @vincentkoc.

src/agents/embedded-agent-runner/run.cross-provider-fallback-error-context.test.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,7 +256,7 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
256256
}),
257257
);
258258

259-
await runEmbeddedAgent({
259+
const result = await runEmbeddedAgent({
260260
...overflowBaseRunParams,
261261
runId: "run-stale-session-assistant-non-timeout",
262262
config: makeCrossProviderFallbackConfig(),
@@ -268,5 +268,10 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
268268

269269
expect(mockedIsFailoverAssistantError).toHaveBeenCalledWith(undefined);
270270
expect(getLastFormattedAssistant()).toBeUndefined();
271+
expect(result.meta.finalAssistantVisibleText).toBeUndefined();
272+
expect(result.meta.agentMeta).toMatchObject({
273+
provider: "deepseek",
274+
model: "test-model",
275+
});
271276
});
272277
});

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

Lines changed: 138 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
import { makeAttemptResult } from "./run.overflow-compaction.fixture.js";
99
import {
1010
loadRunOverflowCompactionHarness,
11+
mockedBuildEmbeddedRunPayloads,
1112
mockedClassifyFailoverReason,
1213
mockedGlobalHookRunner,
1314
mockedIsFailoverAssistantError,
@@ -554,17 +555,19 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
554555
mockedClassifyFailoverReason.mockReturnValue(null);
555556
const finalText =
556557
"1. Verdict: the answer completed cleanly. 2. Evidence: the runner captured final text.";
558+
const finalAssistant = {
559+
role: "assistant",
560+
stopReason: "stop",
561+
provider: "openai",
562+
model: "gpt-5.5",
563+
content: [{ type: "text", text: finalText }],
564+
} as unknown as NonNullable<EmbeddedRunAttemptResult["currentAttemptAssistant"]>;
557565
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
558566
makeAttemptResult({
559567
assistantTexts: [],
560568
timedOut: true,
561-
lastAssistant: {
562-
role: "assistant",
563-
stopReason: "stop",
564-
provider: "openai",
565-
model: "gpt-5.5",
566-
content: [{ type: "text", text: finalText }],
567-
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
569+
lastAssistant: finalAssistant,
570+
currentAttemptAssistant: finalAssistant,
568571
}),
569572
);
570573

@@ -617,16 +620,18 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
617620
currentAttemptAssistant: rateLimitAssistant,
618621
}),
619622
);
623+
const recoveredAssistant = {
624+
role: "assistant",
625+
stopReason: "stop",
626+
provider: "openai",
627+
model: "gpt-5.5",
628+
content: [{ type: "text", text: "Recovered after a short rate-limit wait." }],
629+
} as unknown as NonNullable<EmbeddedRunAttemptResult["currentAttemptAssistant"]>;
620630
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
621631
makeAttemptResult({
622632
assistantTexts: ["Recovered after a short rate-limit wait."],
623-
lastAssistant: {
624-
role: "assistant",
625-
stopReason: "stop",
626-
provider: "openai",
627-
model: "gpt-5.5",
628-
content: [{ type: "text", text: "Recovered after a short rate-limit wait." }],
629-
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
633+
lastAssistant: recoveredAssistant,
634+
currentAttemptAssistant: recoveredAssistant,
630635
}),
631636
);
632637

@@ -969,16 +974,18 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
969974
currentAttemptAssistant: undefined,
970975
}),
971976
);
977+
const recoveredAssistant = {
978+
role: "assistant",
979+
stopReason: "end_turn",
980+
provider: "openai",
981+
model: "gpt-5.5",
982+
content: [{ type: "text", text: "Recovered answer." }],
983+
} as unknown as NonNullable<EmbeddedRunAttemptResult["currentAttemptAssistant"]>;
972984
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
973985
makeAttemptResult({
974986
assistantTexts: ["Recovered answer."],
975-
lastAssistant: {
976-
role: "assistant",
977-
stopReason: "end_turn",
978-
provider: "openai",
979-
model: "gpt-5.5",
980-
content: [{ type: "text", text: "Recovered answer." }],
981-
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
987+
lastAssistant: recoveredAssistant,
988+
currentAttemptAssistant: recoveredAssistant,
982989
}),
983990
);
984991

@@ -1381,6 +1388,62 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
13811388
).toBe(true);
13821389
});
13831390

1391+
it("does not flag stale lastAssistant=toolUse when currentAttemptAssistant=stop exists (#80918)", () => {
1392+
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1393+
payloadCount: 1,
1394+
aborted: false,
1395+
timedOut: false,
1396+
attempt: makeAttemptResult({
1397+
assistantTexts: ["Analysis...", "Here is the final answer after update_plan."],
1398+
toolMetas: [{ toolName: "update_plan" }],
1399+
lastAssistant: {
1400+
role: "assistant",
1401+
stopReason: "toolUse",
1402+
provider: "openai",
1403+
model: "gpt-5.5",
1404+
content: [
1405+
{ type: "text", text: "Analysis..." },
1406+
{ type: "tool_use", id: "tool_1", name: "update_plan", input: {} },
1407+
],
1408+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1409+
currentAttemptAssistant: {
1410+
role: "assistant",
1411+
stopReason: "stop",
1412+
provider: "openai",
1413+
model: "gpt-5.5",
1414+
content: [{ type: "text", text: "Here is the final answer after update_plan." }],
1415+
} as unknown as EmbeddedRunAttemptResult["currentAttemptAssistant"],
1416+
}),
1417+
});
1418+
1419+
expect(incompleteTurnText).toBeNull();
1420+
});
1421+
1422+
it("still flags incomplete-turn when currentAttemptAssistant is absent and lastAssistant=toolUse (#76477 regression)", () => {
1423+
const incompleteTurnText = resolveIncompleteTurnPayloadText({
1424+
payloadCount: 1,
1425+
aborted: false,
1426+
timedOut: false,
1427+
attempt: makeAttemptResult({
1428+
assistantTexts: ["Let me update the file..."],
1429+
toolMetas: [{ toolName: "write" }],
1430+
lastAssistant: {
1431+
role: "assistant",
1432+
stopReason: "toolUse",
1433+
provider: "openai",
1434+
model: "gpt-5.4",
1435+
content: [
1436+
{ type: "text", text: "Let me update the file..." },
1437+
{ type: "tool_use", id: "tool_1", name: "write", input: {} },
1438+
],
1439+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1440+
currentAttemptAssistant: undefined,
1441+
}),
1442+
});
1443+
1444+
expect(incompleteTurnText).toContain("couldn't generate a response");
1445+
});
1446+
13841447
it("surfaces no-visible-answer recovery for app-server interrupted tool-only output", () => {
13851448
const interruptedToolOnlyAttempt = makeAttemptResult({
13861449
assistantTexts: [],
@@ -1675,6 +1738,60 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
16751738
expectWarnMessageWith("incomplete turn detected");
16761739
});
16771740

1741+
it("delivers the current final answer when the session assistant is stale (#80918)", async () => {
1742+
mockedClassifyFailoverReason.mockReturnValue(null);
1743+
const finalText = "The requested update is complete.";
1744+
mockedBuildEmbeddedRunPayloads.mockReturnValueOnce([{ text: finalText }]);
1745+
mockedRunEmbeddedAttempt.mockResolvedValueOnce(
1746+
makeAttemptResult({
1747+
assistantTexts: [finalText],
1748+
toolMetas: [{ toolName: "update_plan", replaySafe: true }],
1749+
lastAssistant: {
1750+
role: "assistant",
1751+
stopReason: "toolUse",
1752+
provider: "openai",
1753+
model: "gpt-5.5",
1754+
content: [{ type: "tool_use", id: "tool_1", name: "update_plan", input: {} }],
1755+
usage: { input: 100, output: 5, total: 105 },
1756+
} as unknown as EmbeddedRunAttemptResult["lastAssistant"],
1757+
currentAttemptAssistant: {
1758+
role: "assistant",
1759+
stopReason: "stop",
1760+
provider: "openai",
1761+
model: "gpt-5.5",
1762+
content: [{ type: "text", text: finalText }],
1763+
usage: { input: 200, output: 20, total: 220 },
1764+
} as unknown as EmbeddedRunAttemptResult["currentAttemptAssistant"],
1765+
}),
1766+
);
1767+
1768+
const result = await runEmbeddedAgent({
1769+
...overflowBaseRunParams,
1770+
provider: "openai",
1771+
model: "gpt-5.5",
1772+
runId: "run-current-assistant-after-tool-use",
1773+
});
1774+
1775+
expect(result.payloads).toEqual([{ text: finalText }]);
1776+
expect(mockedBuildEmbeddedRunPayloads).toHaveBeenCalledWith(
1777+
expect.objectContaining({
1778+
currentAssistant: expect.objectContaining({
1779+
stopReason: "stop",
1780+
content: [{ type: "text", text: finalText }],
1781+
}),
1782+
lastAssistant: expect.objectContaining({ stopReason: "toolUse" }),
1783+
}),
1784+
);
1785+
expect(result.meta.finalAssistantVisibleText).toBe(finalText);
1786+
expect(result.meta.stopReason).toBe("stop");
1787+
expect(result.meta.agentMeta?.lastCallUsage).toMatchObject({
1788+
input: 200,
1789+
output: 20,
1790+
total: 220,
1791+
});
1792+
expectNoWarnMessageWith("incomplete turn detected");
1793+
});
1794+
16781795
it("treats missing replay metadata as replay-invalid", () => {
16791796
const attempt = makeAttemptResult();
16801797
delete (attempt as Partial<EmbeddedRunAttemptResult>).replayMetadata;

0 commit comments

Comments
 (0)