|
1 | 1 | import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; |
2 | 2 | import type { OpenClawConfig } from "../../config/config.js"; |
3 | | -import { hasCommittedMessagingToolDeliveryEvidence } from "./delivery-evidence.js"; |
| 3 | +import { |
| 4 | + hasCommittedMessagingToolDeliveryEvidence, |
| 5 | + hasOutboundDeliveryEvidence, |
| 6 | +} from "./delivery-evidence.js"; |
4 | 7 | import { makeAttemptResult } from "./run.overflow-compaction.fixture.js"; |
5 | 8 | import { |
6 | 9 | loadRunOverflowCompactionHarness, |
@@ -1484,6 +1487,34 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { |
1484 | 1487 | expect(retryInstruction).toBe(EMPTY_RESPONSE_RETRY_INSTRUCTION); |
1485 | 1488 | }); |
1486 | 1489 |
|
| 1490 | + it("does not retry empty turns after an accepted sessions_spawn delivery", () => { |
| 1491 | + const retryInstruction = resolveEmptyResponseRetryInstruction({ |
| 1492 | + provider: "ollama", |
| 1493 | + modelId: "gemma4:31b", |
| 1494 | + payloadCount: 0, |
| 1495 | + aborted: false, |
| 1496 | + timedOut: false, |
| 1497 | + attempt: makeAttemptResult({ |
| 1498 | + assistantTexts: [], |
| 1499 | + acceptedSessionSpawns: [ |
| 1500 | + { |
| 1501 | + runId: "run-child", |
| 1502 | + childSessionKey: "agent:claude:subagent:child", |
| 1503 | + }, |
| 1504 | + ], |
| 1505 | + lastAssistant: { |
| 1506 | + role: "assistant", |
| 1507 | + stopReason: "end_turn", |
| 1508 | + provider: "ollama", |
| 1509 | + model: "gemma4:31b", |
| 1510 | + content: [{ type: "text", text: "" }], |
| 1511 | + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], |
| 1512 | + }), |
| 1513 | + }); |
| 1514 | + |
| 1515 | + expect(retryInstruction).toBeNull(); |
| 1516 | + }); |
| 1517 | + |
1487 | 1518 | it("retries generic empty OpenAI-compatible turns from custom endpoints", () => { |
1488 | 1519 | const retryInstruction = resolveEmptyResponseRetryInstruction({ |
1489 | 1520 | provider: "llama-cpp-local", |
@@ -1654,6 +1685,100 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { |
1654 | 1685 | expect(incompleteTurnText).toBeNull(); |
1655 | 1686 | }); |
1656 | 1687 |
|
| 1688 | + it("suppresses the incomplete-turn warning after an accepted sessions_spawn terminal success", () => { |
| 1689 | + const attemptWithAcceptedSpawn: Partial<EmbeddedRunAttemptResult> & { |
| 1690 | + acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>; |
| 1691 | + } = { |
| 1692 | + assistantTexts: [], |
| 1693 | + acceptedSessionSpawns: [ |
| 1694 | + { |
| 1695 | + runId: "run-child", |
| 1696 | + childSessionKey: "agent:claude:subagent:child", |
| 1697 | + }, |
| 1698 | + ], |
| 1699 | + lastAssistant: { |
| 1700 | + role: "assistant", |
| 1701 | + stopReason: "stop", |
| 1702 | + provider: "anthropic", |
| 1703 | + model: "sonnet-4.6", |
| 1704 | + content: [], |
| 1705 | + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], |
| 1706 | + }; |
| 1707 | + |
| 1708 | + const incompleteTurnText = resolveIncompleteTurnPayloadText({ |
| 1709 | + payloadCount: 0, |
| 1710 | + aborted: false, |
| 1711 | + timedOut: false, |
| 1712 | + attempt: makeAttemptResult(attemptWithAcceptedSpawn), |
| 1713 | + }); |
| 1714 | + |
| 1715 | + expect(incompleteTurnText).toBeNull(); |
| 1716 | + }); |
| 1717 | + |
| 1718 | + it("still returns a timeout payload when the parent prompt times out after an accepted sessions_spawn", async () => { |
| 1719 | + const acceptedSessionSpawns = [ |
| 1720 | + { |
| 1721 | + runId: "run-child", |
| 1722 | + childSessionKey: "agent:claude:subagent:child", |
| 1723 | + }, |
| 1724 | + ]; |
| 1725 | + mockedClassifyFailoverReason.mockReturnValue(null); |
| 1726 | + mockedRunEmbeddedAttempt.mockResolvedValueOnce( |
| 1727 | + makeAttemptResult({ |
| 1728 | + assistantTexts: [], |
| 1729 | + acceptedSessionSpawns, |
| 1730 | + timedOut: true, |
| 1731 | + lastAssistant: { |
| 1732 | + role: "assistant", |
| 1733 | + stopReason: "toolUse", |
| 1734 | + provider: "openai", |
| 1735 | + model: "gpt-5.4", |
| 1736 | + content: [], |
| 1737 | + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], |
| 1738 | + }), |
| 1739 | + ); |
| 1740 | + |
| 1741 | + const result = await runEmbeddedPiAgent({ |
| 1742 | + ...overflowBaseRunParams, |
| 1743 | + provider: "openai", |
| 1744 | + model: "gpt-5.4", |
| 1745 | + runId: "run-timeout-after-accepted-spawn", |
| 1746 | + }); |
| 1747 | + |
| 1748 | + expect(result.payloads).toEqual([ |
| 1749 | + { |
| 1750 | + text: "Request timed out before a response was generated. Please try again, or increase `agents.defaults.timeoutSeconds` in your config.", |
| 1751 | + isError: true, |
| 1752 | + }, |
| 1753 | + ]); |
| 1754 | + expect(result.acceptedSessionSpawns).toEqual(acceptedSessionSpawns); |
| 1755 | + }); |
| 1756 | + |
| 1757 | + it("still surfaces the incomplete-turn warning without an accepted sessions_spawn success", () => { |
| 1758 | + const attemptWithMalformedSpawn: Partial<EmbeddedRunAttemptResult> & { |
| 1759 | + acceptedSessionSpawns: Array<{ runId: string; childSessionKey: string }>; |
| 1760 | + } = { |
| 1761 | + assistantTexts: [], |
| 1762 | + acceptedSessionSpawns: [], |
| 1763 | + lastAssistant: { |
| 1764 | + role: "assistant", |
| 1765 | + stopReason: "stop", |
| 1766 | + provider: "anthropic", |
| 1767 | + model: "sonnet-4.6", |
| 1768 | + content: [], |
| 1769 | + } as unknown as EmbeddedRunAttemptResult["lastAssistant"], |
| 1770 | + }; |
| 1771 | + |
| 1772 | + const incompleteTurnText = resolveIncompleteTurnPayloadText({ |
| 1773 | + payloadCount: 0, |
| 1774 | + aborted: false, |
| 1775 | + timedOut: false, |
| 1776 | + attempt: makeAttemptResult(attemptWithMalformedSpawn), |
| 1777 | + }); |
| 1778 | + |
| 1779 | + expect(incompleteTurnText).toContain("couldn't generate a response"); |
| 1780 | + }); |
| 1781 | + |
1657 | 1782 | it("still surfaces the incomplete-turn warning when no messaging delivery was committed", () => { |
1658 | 1783 | const incompleteTurnText = resolveIncompleteTurnPayloadText({ |
1659 | 1784 | payloadCount: 0, |
@@ -1738,6 +1863,40 @@ describe("runEmbeddedPiAgent incomplete-turn safety", () => { |
1738 | 1863 | ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); |
1739 | 1864 | }); |
1740 | 1865 |
|
| 1866 | + it("treats accepted sessions_spawn as replay-invalid outbound delivery", () => { |
| 1867 | + const acceptedSessionSpawns = [ |
| 1868 | + { |
| 1869 | + runId: "run-child", |
| 1870 | + childSessionKey: "agent:claude:subagent:child", |
| 1871 | + }, |
| 1872 | + ]; |
| 1873 | + |
| 1874 | + expect( |
| 1875 | + buildAttemptReplayMetadata({ |
| 1876 | + toolMetas: [], |
| 1877 | + didSendViaMessagingTool: false, |
| 1878 | + messagingToolSentTexts: [], |
| 1879 | + messagingToolSentMediaUrls: [], |
| 1880 | + acceptedSessionSpawns, |
| 1881 | + }), |
| 1882 | + ).toEqual({ hadPotentialSideEffects: true, replaySafe: false }); |
| 1883 | + expect(hasOutboundDeliveryEvidence({ acceptedSessionSpawns })).toBe(true); |
| 1884 | + }); |
| 1885 | + |
| 1886 | + it("ignores malformed accepted sessions_spawn delivery evidence", () => { |
| 1887 | + expect( |
| 1888 | + hasOutboundDeliveryEvidence({ |
| 1889 | + acceptedSessionSpawns: [ |
| 1890 | + null, |
| 1891 | + { |
| 1892 | + runId: "run-child", |
| 1893 | + childSessionKey: " ", |
| 1894 | + }, |
| 1895 | + ], |
| 1896 | + }), |
| 1897 | + ).toBe(false); |
| 1898 | + }); |
| 1899 | + |
1741 | 1900 | it("leaves committed delivery plus tool errors to the tool-error payload path", () => { |
1742 | 1901 | const incompleteTurnText = resolveIncompleteTurnPayloadText({ |
1743 | 1902 | payloadCount: 0, |
|
0 commit comments