|
8 | 8 | import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; |
9 | 9 | import { |
10 | 10 | loadRunOverflowCompactionHarness, |
| 11 | + mockedBuildEmbeddedRunPayloads, |
11 | 12 | mockedClassifyFailoverReason, |
12 | 13 | mockedGlobalHookRunner, |
13 | 14 | mockedIsFailoverAssistantError, |
@@ -554,17 +555,19 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { |
554 | 555 | mockedClassifyFailoverReason.mockReturnValue(null); |
555 | 556 | const finalText = |
556 | 557 | "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"]>; |
557 | 565 | mockedRunEmbeddedAttempt.mockResolvedValueOnce( |
558 | 566 | makeAttemptResult({ |
559 | 567 | assistantTexts: [], |
560 | 568 | 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, |
568 | 571 | }), |
569 | 572 | ); |
570 | 573 |
|
@@ -617,16 +620,18 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { |
617 | 620 | currentAttemptAssistant: rateLimitAssistant, |
618 | 621 | }), |
619 | 622 | ); |
| 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"]>; |
620 | 630 | mockedRunEmbeddedAttempt.mockResolvedValueOnce( |
621 | 631 | makeAttemptResult({ |
622 | 632 | 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, |
630 | 635 | }), |
631 | 636 | ); |
632 | 637 |
|
@@ -969,16 +974,18 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { |
969 | 974 | currentAttemptAssistant: undefined, |
970 | 975 | }), |
971 | 976 | ); |
| 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"]>; |
972 | 984 | mockedRunEmbeddedAttempt.mockResolvedValueOnce( |
973 | 985 | makeAttemptResult({ |
974 | 986 | 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, |
982 | 989 | }), |
983 | 990 | ); |
984 | 991 |
|
@@ -1381,6 +1388,62 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { |
1381 | 1388 | ).toBe(true); |
1382 | 1389 | }); |
1383 | 1390 |
|
| 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 | + |
1384 | 1447 | it("surfaces no-visible-answer recovery for app-server interrupted tool-only output", () => { |
1385 | 1448 | const interruptedToolOnlyAttempt = makeAttemptResult({ |
1386 | 1449 | assistantTexts: [], |
@@ -1675,6 +1738,60 @@ describe("runEmbeddedAgent incomplete-turn safety", () => { |
1675 | 1738 | expectWarnMessageWith("incomplete turn detected"); |
1676 | 1739 | }); |
1677 | 1740 |
|
| 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 | + |
1678 | 1795 | it("treats missing replay metadata as replay-invalid", () => { |
1679 | 1796 | const attempt = makeAttemptResult(); |
1680 | 1797 | delete (attempt as Partial<EmbeddedRunAttemptResult>).replayMetadata; |
|
0 commit comments