diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index e73e90c7ca59..c9548a6cce13 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -547,8 +547,6 @@ src/auto-reply/reply/dispatch-from-config.progress.test-utils.ts src/auto-reply/reply/dispatch-from-config.routing.test-utils.ts src/auto-reply/reply/dispatch-from-config.send-policy-routing.test-utils.ts src/auto-reply/reply/dispatch-from-config.shared.test-harness.ts -src/auto-reply/reply/followup-runner.test.ts -src/auto-reply/reply/followup-runner.ts src/auto-reply/reply/get-reply-inline-actions.skip-when-config-empty.test.ts src/auto-reply/reply/get-reply-run.media-only.test.ts src/auto-reply/reply/get-reply.ts diff --git a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts index 6b0b10f9acaa..fc648e06b6ae 100644 --- a/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts +++ b/src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts @@ -31,7 +31,7 @@ const createReplyMediaContextMock = vi.fn(); const createReplyMediaPathNormalizerMock = vi.fn(); const runPreflightCompactionIfNeededMock = vi.fn(); const runMemoryFlushIfNeededMock = vi.fn(); -const runAgentTurnWithFallbackMock = vi.fn(); +const executeAgentTurnMock = vi.fn(); const resetReplyRunSessionMock = vi.fn(); const enqueueFollowupRunMock = vi.fn(); @@ -79,7 +79,7 @@ vi.mock("./agent-runner-execution.js", async () => { ); return { ...actual, - runAgentTurnWithFallback: (...args: unknown[]) => runAgentTurnWithFallbackMock(...args), + executeAgentTurn: (...args: unknown[]) => executeAgentTurnMock(...args), }; }); @@ -242,7 +242,7 @@ describe("runReplyAgent runtime config", () => { createReplyMediaPathNormalizerMock.mockReset(); runPreflightCompactionIfNeededMock.mockReset(); runMemoryFlushIfNeededMock.mockReset(); - runAgentTurnWithFallbackMock.mockReset(); + executeAgentTurnMock.mockReset(); resetReplyRunSessionMock.mockReset(); enqueueFollowupRunMock.mockReset(); @@ -252,9 +252,9 @@ describe("runReplyAgent runtime config", () => { createReplyMediaPathNormalizerMock.mockReturnValue((payload: unknown) => payload); runPreflightCompactionIfNeededMock.mockRejectedValue(sentinelError); runMemoryFlushIfNeededMock.mockResolvedValue({ sessionEntry: undefined, outcome: "skipped" }); - runAgentTurnWithFallbackMock.mockResolvedValue({ - kind: "final", - payload: { text: "main reply" }, + executeAgentTurnMock.mockResolvedValue({ + runId: "runtime-config-test", + outcome: { kind: "rejected", payload: { text: "main reply" } }, }); resetReplyRunSessionMock.mockResolvedValue(false); }); @@ -358,7 +358,7 @@ describe("runReplyAgent runtime config", () => { expect(result).toEqual({ text: "main reply" }); expect(onBlockReply).not.toHaveBeenCalled(); - expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(executeAgentTurnMock).toHaveBeenCalledOnce(); }); it("rotates, rebinds, and optionally notifies when memory flush is exhausted", async () => { @@ -459,7 +459,7 @@ describe("runReplyAgent runtime config", () => { text: "⚠️ Memory maintenance temporarily failed; continuing your reply.", }), ); - expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(executeAgentTurnMock).toHaveBeenCalledOnce(); }); }); @@ -488,7 +488,7 @@ describe("runReplyAgent runtime config", () => { await expect(runReplyAgent(replyParams)).resolves.toEqual({ text: "main reply" }); expect(resetReplyRunSessionMock).not.toHaveBeenCalled(); - expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(executeAgentTurnMock).toHaveBeenCalledOnce(); }); it("rotates when preflight cannot recover an exhausted memory flush", async () => { @@ -513,7 +513,7 @@ describe("runReplyAgent runtime config", () => { cleanupTranscripts: false, }, }); - expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(executeAgentTurnMock).toHaveBeenCalledOnce(); }); it("surfaces unrelated preflight failures after an exhausted memory flush", async () => { @@ -536,7 +536,7 @@ describe("runReplyAgent runtime config", () => { } expect(result.text).toContain("auto-compaction could not recover"); expect(resetReplyRunSessionMock).not.toHaveBeenCalled(); - expect(runAgentTurnWithFallbackMock).not.toHaveBeenCalled(); + expect(executeAgentTurnMock).not.toHaveBeenCalled(); }); it("does not start the main turn after cancellation during memory flush", async () => { diff --git a/src/auto-reply/reply/agent-runner-error-handler.ts b/src/auto-reply/reply/agent-runner-error-handler.ts index f075a0cc3852..d09f19dc9937 100644 --- a/src/auto-reply/reply/agent-runner-error-handler.ts +++ b/src/auto-reply/reply/agent-runner-error-handler.ts @@ -29,7 +29,7 @@ import { defaultRuntime } from "../../runtime.js"; import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { buildContextOverflowRecoveryText } from "./agent-runner-context-recovery.js"; -import type { AgentRunLoopResult, AgentTurnParams } from "./agent-runner-execution.types.js"; +import type { AgentTurnInternalResult, AgentTurnParams } from "./agent-runner-execution.types.js"; import { buildControlUiAgentFailureText, GENERIC_EXTERNAL_RUN_FAILURE_TEXT, @@ -106,7 +106,7 @@ export async function cancelOverloadRetryNotice(state: OverloadRetryState): Prom type ErrorAction = | { kind: "retry"; liveModelSwitchError?: LiveSessionModelSwitchError } - | Extract; + | Extract; export async function handleAgentExecutionError(params: { turn: AgentTurnParams; diff --git a/src/auto-reply/reply/agent-runner-execute.ts b/src/auto-reply/reply/agent-runner-execute.ts index 3058b363cba8..6132f207a7df 100644 --- a/src/auto-reply/reply/agent-runner-execute.ts +++ b/src/auto-reply/reply/agent-runner-execute.ts @@ -14,7 +14,7 @@ import { resolveSourceReplyPolicy, type RunReplyAgentParams, } from "./agent-runner-core.js"; -import { runAgentTurnWithFallback } from "./agent-runner-execution.js"; +import { executeAgentTurn } from "./agent-runner-execution.js"; import { runMemoryFlushIfNeeded, runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; import { finalizeReplyAgentRun } from "./agent-runner-result.js"; import { buildThreadingToolContext } from "./agent-runner-utils.js"; @@ -359,7 +359,7 @@ export async function executePreparedReplyAgentRun( }, () => traceAgentPhase("reply.run_agent_turn", () => - runAgentTurnWithFallback({ + executeAgentTurn({ commandBody, transcriptCommandBody, followupRun, @@ -393,11 +393,15 @@ export async function executePreparedReplyAgentRun( activeSessionEntry = getActiveSessionEntry(); activeIsNewSession = getActiveIsNewSession(); - if (runOutcome.kind === "final") { - if (!replyOperation.result) { + if (runOutcome.outcome.kind !== "settled") { + if (runOutcome.outcome.kind === "rejected" && !replyOperation.result) { replyOperation.fail("run_failed", new Error("reply operation exited with final payload")); } - return returnWithQueuedFollowupDrain(runOutcome.payload); + return returnWithQueuedFollowupDrain( + runOutcome.outcome.kind === "rejected" + ? runOutcome.outcome.payload + : { text: SILENT_REPLY_TOKEN }, + ); } return await finalizeReplyAgentRun({ @@ -427,7 +431,8 @@ export async function executePreparedReplyAgentRun( resolvedVerboseLevel, returnWithQueuedFollowupDrain, runFollowupTurn, - runOutcome, + execution: runOutcome.outcome, + runId: runOutcome.runId, runStartedAt, runtimePolicySessionKey, sessionCtx, diff --git a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts index 4d7d68062bd4..2bae4bf04275 100644 --- a/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-auth-failures.test.ts @@ -5,7 +5,7 @@ import { MissingProviderAuthError } from "../../agents/model-auth.js"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createMinimalRunAgentTurnParams, @@ -13,7 +13,7 @@ import { const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: authentication failures", () => { +describe("executeAgentTurn: authentication failures", () => { it("surfaces gateway reauth guidance for known OAuth refresh failures", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( @@ -21,8 +21,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -62,8 +62,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -91,8 +91,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -130,8 +130,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }); state.runEmbeddedAgentMock.mockRejectedValueOnce(summaryError); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -148,8 +148,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "whatsapp", @@ -176,8 +176,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -207,8 +207,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -231,8 +231,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -252,8 +252,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -270,8 +270,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -310,8 +310,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -332,8 +332,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -354,8 +354,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -371,8 +371,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { new Error('No API key found for provider "openai".'), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -387,8 +387,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { new Error('No API key found for provider "openai`\nrm -rf /".'), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -426,8 +426,8 @@ describe("runAgentTurnWithFallback: authentication failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts b/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts index 2e8cfacfbc90..22530fad63f0 100644 --- a/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-cli-progress.test.ts @@ -3,7 +3,7 @@ import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createMinimalRunAgentTurnParams, @@ -15,7 +15,7 @@ import type { const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: CLI progress bridging", () => { +describe("executeAgentTurn: CLI progress bridging", () => { it("bridges CLI assistant agent events into onPartialReply for live preview (#76869)", async () => { state.isCliProviderMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ @@ -47,12 +47,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onPartialReply = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { @@ -125,12 +125,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { } }, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - const runPromise = runAgentTurnWithFallback({ + const runPromise = executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { @@ -204,12 +204,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { ); const onToolStart = vi.fn>(async () => undefined); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -279,11 +279,11 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const typingSignals = createMockTypingSignaler(); vi.mocked(typingSignals.signalTextDelta).mockReturnValue(typingPending); const callbackOrder: string[] = []; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - const runPromise = runAgentTurnWithFallback({ + const runPromise = executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -371,11 +371,11 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const typingSignals = createMockTypingSignaler(); vi.mocked(typingSignals.signalToolStart).mockReturnValue(typingPending); const callbackOrder: string[] = []; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - const runPromise = runAgentTurnWithFallback({ + const runPromise = executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -441,12 +441,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { ); const onItemEvent = vi.fn>(async () => undefined); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -498,12 +498,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { ); const onItemEvent = vi.fn>(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -557,13 +557,13 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { }); const onToolStart = vi.fn>(async () => undefined); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; followupRun.run.silentExpected = true; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -617,13 +617,13 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onPartialReply = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-6"; followupRun.run.silentExpected = true; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -682,12 +682,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onReasoningStream = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-7"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { @@ -752,13 +752,13 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onReasoningStream = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-opus-4-7"; followupRun.run.silentExpected = true; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -807,12 +807,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onReasoningStream = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.5"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -865,12 +865,12 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onReasoningStream = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "anthropic"; followupRun.run.model = "claude-sonnet-4-7"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hi", followupRun, sessionCtx: { Provider: "telegram", MessageSid: "msg" } as unknown as TemplateContext, @@ -909,9 +909,9 @@ describe("runAgentTurnWithFallback: CLI progress bridging", () => { const onReasoningStream = vi.fn>( async (_payload) => undefined, ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); - await runAgentTurnWithFallback( + await executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onReasoningStream }, }), diff --git a/src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts b/src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts index b70ba7268fbf..6b28a96b726f 100644 --- a/src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-cli-sessions.test.ts @@ -4,7 +4,7 @@ import type { TemplateContext } from "../templating.js"; import { SILENT_REPLY_TOKEN } from "../tokens.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createTestUserTurnRecorder, @@ -17,7 +17,7 @@ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: CLI session routing", () => { +describe("executeAgentTurn: CLI session routing", () => { it("forwards the static extra system prompt to CLI backends", async () => { state.isCliProviderMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ @@ -31,7 +31,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; @@ -54,7 +54,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { followupRun.run.runtimePolicySessionKey = "agent:main:telegram:default:direct:sender-static"; followupRun.originatingChannel = "telegram"; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -112,7 +112,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { meta: { executionTrace: { fallbackUsed: false } }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "claude-cli"; followupRun.run.model = "claude-sonnet-4-6"; @@ -120,7 +120,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { followupRun.run.allowEmptyAssistantReplyAsSilent = true; followupRun.originatingChannel = "telegram"; - const result = await runAgentTurnWithFallback( + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: { @@ -155,7 +155,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; @@ -175,7 +175,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }; const activeSessionStore = { main: sessionEntry }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), commandBody: "runtime prompt", transcriptCommandBody: "display prompt", @@ -224,7 +224,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.currentInboundEventKind = "room_event"; followupRun.run.provider = "codex-cli"; @@ -236,7 +236,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { } as unknown as SessionEntry; const activeSessionStore = { main: sessionEntry }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), activeSessionStore, getActiveSessionEntry: () => sessionEntry, @@ -282,14 +282,14 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.currentInboundEventKind = "room_event"; followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; const sessionEntry = {} as unknown as SessionEntry; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), getActiveSessionEntry: () => sessionEntry, }); @@ -332,7 +332,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.currentInboundEventKind = "room_event"; followupRun.run.provider = "codex-cli"; @@ -344,7 +344,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { } as unknown as SessionEntry; const activeSessionStore = { main: sessionEntry }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), activeSessionStore, getActiveSessionEntry: () => sessionEntry, @@ -386,7 +386,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.currentInboundEventKind = "room_event"; followupRun.run.provider = "codex-cli"; @@ -398,7 +398,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { } as unknown as SessionEntry; const activeSessionStore = { main: sessionEntry }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), activeSessionStore, getActiveSessionEntry: () => sessionEntry, @@ -435,7 +435,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.currentInboundEventKind = "room_event"; followupRun.run.provider = "codex-cli"; @@ -447,7 +447,7 @@ describe("runAgentTurnWithFallback: CLI session routing", () => { } as unknown as SessionEntry; const activeSessionStore = { main: sessionEntry }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), activeSessionStore, getActiveSessionEntry: () => sessionEntry, diff --git a/src/auto-reply/reply/agent-runner-execution-command-events.test.ts b/src/auto-reply/reply/agent-runner-execution-command-events.test.ts index 45efed26dd32..901d133fb51a 100644 --- a/src/auto-reply/reply/agent-runner-execution-command-events.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-command-events.test.ts @@ -3,7 +3,7 @@ import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, } from "./agent-runner-execution.test-support.js"; @@ -11,7 +11,7 @@ import type { EmbeddedAgentParams } from "./agent-runner-execution.test-support. const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: command events", () => { +describe("executeAgentTurn: command events", () => { it("forwards plan, approval, command output, and patch events", async () => { const onPlanUpdate = vi.fn(); const onApprovalEvent = vi.fn(); @@ -69,9 +69,9 @@ describe("runAgentTurnWithFallback: command events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -171,8 +171,8 @@ describe("runAgentTurnWithFallback: command events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -228,8 +228,8 @@ describe("runAgentTurnWithFallback: command events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -290,8 +290,8 @@ describe("runAgentTurnWithFallback: command events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-compaction.test.ts b/src/auto-reply/reply/agent-runner-execution-compaction.test.ts index 9101ac67192f..88d0c136dc59 100644 --- a/src/auto-reply/reply/agent-runner-execution-compaction.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-compaction.test.ts @@ -4,7 +4,7 @@ import { loggingState } from "../../logging/state.js"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, expectBlockReplyCall, @@ -17,7 +17,7 @@ import type { const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: compaction events", () => { +describe("executeAgentTurn: compaction events", () => { it("keeps compaction start notices silent by default", async () => { const onBlockReply = vi.fn(); state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { @@ -25,8 +25,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -66,8 +66,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -144,8 +144,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onBlockReply }, }), @@ -181,8 +181,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -237,8 +237,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -301,8 +301,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -377,8 +377,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -439,8 +439,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -496,8 +496,8 @@ describe("runAgentTurnWithFallback: compaction events", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts index b81079688e32..0b38a8a9fe4e 100644 --- a/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-context-failures.test.ts @@ -7,7 +7,7 @@ import { setupAgentRunnerExecutionTestState, GENERIC_RUN_FAILURE_TEXT, makeTestModel, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createFollowupRun, createMockReplyOperation, requireRecord, @@ -18,7 +18,7 @@ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: context failures", () => { +describe("executeAgentTurn: context failures", () => { it("preserves the active session when embedded overflow recovery fails", async () => { state.isContextOverflowErrorMock.mockReturnValue(true); state.runEmbeddedAgentMock.mockResolvedValueOnce({ @@ -33,8 +33,8 @@ describe("runAgentTurnWithFallback: context failures", () => { const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; const activeSessionStore = { "agent:main:main": activeSessionEntry }; const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "webchat", @@ -76,8 +76,8 @@ describe("runAgentTurnWithFallback: context failures", () => { const activeSessionEntry = { sessionId: "session", updatedAt: 1 } as SessionEntry; const activeSessionStore = { "agent:main:main": activeSessionEntry }; const { replyOperation, failMock, updateSessionIdMock } = createMockReplyOperation(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "webchat", @@ -134,8 +134,8 @@ describe("runAgentTurnWithFallback: context failures", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const resultPromise = runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const resultPromise = executeAgentTurn(createMinimalRunAgentTurnParams()); await vi.advanceTimersByTimeAsync(2_500); const result = await resultPromise; @@ -157,8 +157,8 @@ describe("runAgentTurnWithFallback: context failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "telegram", @@ -203,8 +203,8 @@ describe("runAgentTurnWithFallback: context failures", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams({ followupRun })); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun })); expect(result.kind).toBe("final"); if (result.kind === "final") { diff --git a/src/auto-reply/reply/agent-runner-execution-contract.test.ts b/src/auto-reply/reply/agent-runner-execution-contract.test.ts new file mode 100644 index 000000000000..517ac16b6f5a --- /dev/null +++ b/src/auto-reply/reply/agent-runner-execution-contract.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from "vitest"; +import { createAgentRunRestartAbortError } from "../../agents/run-termination.js"; +import { + createMinimalRunAgentTurnParams, + createMockReplyOperation, + setupAgentRunnerExecutionTestState, +} from "./agent-runner-execution.test-support.js"; + +const state = setupAgentRunnerExecutionTestState(); +const { executeAgentTurn } = await import("./agent-runner-execution.js"); + +describe("executeAgentTurn contract", () => { + it("returns one closed settled result with winner and fallback facts", async () => { + state.runEmbeddedAgentMock.mockResolvedValue({ + payloads: [{ text: "done" }], + meta: { + durationMs: 1, + agentMeta: { provider: "anthropic", model: "claude-sonnet" }, + }, + }); + + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); + + expect(result).toMatchObject({ + runId: expect.any(String), + outcome: { + kind: "settled", + status: "ok", + resolved: { provider: "anthropic", model: "claude" }, + fallback: { exhausted: false, attempts: [] }, + result: { payloads: [{ text: "done" }] }, + }, + }); + }); + + it("retains a late completed result for accounting after user abort was accepted", async () => { + state.runEmbeddedAgentMock.mockResolvedValue({ + payloads: [{ text: "late reply" }], + meta: { durationMs: 1 }, + }); + const { replyOperation } = createMockReplyOperation(); + let operationResult: typeof replyOperation.result = null; + const lateAbortedOperation = { + ...replyOperation, + get result() { + return operationResult; + }, + freezeAbort: () => { + operationResult = { kind: "aborted", code: "aborted_by_user" }; + }, + }; + + const result = await executeAgentTurn( + createMinimalRunAgentTurnParams({ replyOperation: lateAbortedOperation }), + ); + + expect(result.outcome).toMatchObject({ + kind: "settled", + abortReason: "user", + result: { payloads: [{ text: "late reply" }] }, + }); + }); + + it("releases an unsettled operation when a restart error aborts execution", async () => { + const { replyOperation } = createMockReplyOperation(); + const complete = vi.fn(); + const unsettledOperation = { + ...replyOperation, + complete, + freezeAbort: () => { + throw createAgentRunRestartAbortError(); + }, + }; + + const result = await executeAgentTurn( + createMinimalRunAgentTurnParams({ replyOperation: unsettledOperation }), + ); + + expect(result.outcome).toEqual({ kind: "aborted", reason: "restart" }); + expect(complete).toHaveBeenCalledOnce(); + }); +}); diff --git a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts index a36eafcd0df4..3db3b7c26a15 100644 --- a/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-conversation-failures.test.ts @@ -2,7 +2,7 @@ import { describe, expect, it, vi } from "vitest"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, } from "./agent-runner-execution.test-support.js"; @@ -10,7 +10,7 @@ import { PROVIDER_CONVERSATION_STATE_ERROR_USER_MESSAGE } from "./provider-reque const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: conversation failures", () => { +describe("executeAgentTurn: conversation failures", () => { it("returns a session reset hint for Bedrock tool mismatch errors on external chat channels", async () => { state.runEmbeddedAgentMock.mockRejectedValueOnce( new Error( @@ -18,8 +18,8 @@ describe("runAgentTurnWithFallback: conversation failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -53,8 +53,8 @@ describe("runAgentTurnWithFallback: conversation failures", () => { new Error("Custom tool call output is missing for call id: call_live_123."), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -87,8 +87,8 @@ describe("runAgentTurnWithFallback: conversation failures", () => { const resetSessionAfterRoleOrderingConflict = vi.fn(async () => true); state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("400 Incorrect role information")); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -123,8 +123,8 @@ describe("runAgentTurnWithFallback: conversation failures", () => { const providerError = "provider failed with actionable details"; state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error(providerError)); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts b/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts index af701bf2d626..5819b9dbeef3 100644 --- a/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-lifecycle.test.ts @@ -8,7 +8,7 @@ import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createMockReplyOperation, @@ -24,7 +24,7 @@ import { createReplyOperation, type ReplyOperation } from "./reply-run-registry. const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { +describe("executeAgentTurn: run lifecycle and ownership", () => { it("passes the reply abort signal to fallback orchestration and candidates", async () => { const { replyOperation } = createMockReplyOperation(); state.runEmbeddedAgentMock.mockResolvedValueOnce({ @@ -32,8 +32,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), replyOperation, }); @@ -69,8 +69,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }); try { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), replyOperation, }); @@ -103,8 +103,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }); state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), }); @@ -139,8 +139,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), replyOperation, }); @@ -199,8 +199,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const pending = runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const pending = executeAgentTurn({ ...createMinimalRunAgentTurnParams(), replyOperation, pendingToolTasks, @@ -260,8 +260,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }); try { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const pending = runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const pending = executeAgentTurn({ ...createMinimalRunAgentTurnParams(), replyOperation, }); @@ -314,8 +314,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }); try { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const pending = runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const pending = executeAgentTurn({ ...createMinimalRunAgentTurnParams(), replyOperation, }); @@ -326,8 +326,7 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { await expect(pending).resolves.toEqual({ kind: "final", payload: { - isError: true, - text: "⚠️ Gateway is restarting. Please wait a few seconds and try again.", + text: SILENT_REPLY_TOKEN, }, }); } finally { @@ -347,8 +346,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { followupRun.originatingAccountId = "work"; followupRun.originatingChatType = "direct"; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: { @@ -377,8 +376,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onAgentRunStart, @@ -408,13 +407,13 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { return { payloads: [{ text: "ok" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), commandBody: "show details", transcriptCommandBody: "show details", }); - await runAgentTurnWithFallback({ + await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), commandBody: "next question", transcriptCommandBody: "next question", @@ -438,8 +437,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { ); state.resolveCurrentTurnImagesMock.mockRejectedValueOnce(new Error("invalid image")); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await expect(runAgentTurnWithFallback(createMinimalRunAgentTurnParams())).rejects.toThrow( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await expect(executeAgentTurn(createMinimalRunAgentTurnParams())).rejects.toThrow( "invalid image", ); state.resolveCurrentTurnImagesMock.mockResolvedValueOnce({}); @@ -447,7 +446,7 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { params.onExecutionPhase?.({ phase: "model_call_started" }); return { payloads: [{ text: "ok" }], meta: {} }; }); - await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock.mock.calls[0]?.[0]?.prompt).toContain("still pending"); }); @@ -476,8 +475,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { followupRun.media = [{ path: "/tmp/cli.png", contentType: "image/png" }]; const typingSignals = createMockTypingSignaler(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, typingSignals, @@ -519,8 +518,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, }), @@ -556,8 +555,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { }); params.isHeartbeat = true; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback(params); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn(params); expectMockCallArgFields(state.runCliAgentMock, 0, "CLI run params", { trigger: "heartbeat", @@ -581,8 +580,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const runPromise = runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const runPromise = executeAgentTurn(createMinimalRunAgentTurnParams()); expect(registerAgentRunContext).toHaveBeenCalledWith( expect.any(String), @@ -602,9 +601,9 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { const clearAgentRunContext = vi.mocked(agentEvents.clearAgentRunContext); state.resolveCurrentTurnImagesMock.mockRejectedValueOnce(new Error("invalid image metadata")); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); await expect( - runAgentTurnWithFallback( + executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { runId: "preflight-failure" }, }), @@ -621,8 +620,8 @@ describe("runAgentTurnWithFallback: run lifecycle and ownership", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { toolsAllow: ["message"], diff --git a/src/auto-reply/reply/agent-runner-execution-message-tools.test.ts b/src/auto-reply/reply/agent-runner-execution-message-tools.test.ts index 0a090632130f..485f6549d2cb 100644 --- a/src/auto-reply/reply/agent-runner-execution-message-tools.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-message-tools.test.ts @@ -3,7 +3,7 @@ import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, } from "./agent-runner-execution.test-support.js"; @@ -15,7 +15,7 @@ import type { InternalGetReplyOptions } from "./get-reply.types.js"; const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: message tool progress", () => { +describe("executeAgentTurn: message tool progress", () => { it("suppresses progress callbacks after message-tool-only delivery completes", async () => { let releaseItemEvent: (() => void) | undefined; const itemEventGate = new Promise((resolve) => { @@ -82,10 +82,10 @@ describe("runAgentTurnWithFallback: message tool progress", () => { return { payloads: [{ text: "NO_REPLY" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.sourceReplyDeliveryMode = "message_tool_only"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -174,10 +174,10 @@ describe("runAgentTurnWithFallback: message tool progress", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.sourceReplyDeliveryMode = "message_tool_only"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { Provider: "discord", MessageSid: "msg" } as unknown as TemplateContext, @@ -256,10 +256,10 @@ describe("runAgentTurnWithFallback: message tool progress", () => { return { payloads: [{ text: "NO_REPLY" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.sourceReplyDeliveryMode = "message_tool_only"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -347,10 +347,10 @@ describe("runAgentTurnWithFallback: message tool progress", () => { return { payloads: [{ text: "NO_REPLY" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.sourceReplyDeliveryMode = "message_tool_only"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-probes.test.ts b/src/auto-reply/reply/agent-runner-execution-probes.test.ts index 44b0d938a9a8..36a3ff32061d 100644 --- a/src/auto-reply/reply/agent-runner-execution-probes.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-probes.test.ts @@ -7,7 +7,7 @@ import { resolveRunAfterAutoFallbackPrimaryProbeRecheck } from "./agent-runner-a import { setupAgentRunnerExecutionTestState, GENERIC_RUN_FAILURE_TEXT, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createFollowupRun, createMockReplyOperation, expectRecordFields, @@ -22,7 +22,7 @@ import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: primary probe routing", () => { +describe("executeAgentTurn: primary probe routing", () => { it("rechecks queued auto fallback primary probes before running", async () => { const { markAutoFallbackPrimaryProbe } = await import("../../agents/agent-scope.js"); const probe = { @@ -155,8 +155,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), sessionKey, activeSessionStore, @@ -313,8 +313,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { const { replyOperation, failMock, retainFailureUntilCompleteMock } = createMockReplyOperation(); const emitAgentEvent = vi.mocked((await import("../../infra/agent-events.js")).emitAgentEvent); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun, replyOperation }), sessionKey, activeSessionStore, @@ -386,8 +386,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { const { replyOperation, failMock, retainFailureUntilCompleteMock } = createMockReplyOperation(); const emitAgentEvent = vi.mocked((await import("../../infra/agent-events.js")).emitAgentEvent); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ replyOperation, opts: { runId: "run-non-fallbackable-error" }, @@ -462,8 +462,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { })); const { replyOperation, failMock } = createMockReplyOperation(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ replyOperation }), isHeartbeat: testCase.isHeartbeat, }); @@ -505,8 +505,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { const { replyOperation, failMock, retainFailureUntilCompleteMock } = createMockReplyOperation(); const emitAgentEvent = vi.mocked((await import("../../infra/agent-events.js")).emitAgentEvent); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, replyOperation, @@ -555,8 +555,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { followupRun.run.model = "gpt-5.4"; const emitAgentEvent = vi.mocked((await import("../../infra/agent-events.js")).emitAgentEvent); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, opts: { runId: "run-cli-timeout" }, @@ -607,8 +607,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { .mockResolvedValueOnce({ payloads: [], meta: {} }) .mockResolvedValueOnce({ payloads: [{ text: "fallback" }], meta: {} }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback(createMinimalRunAgentTurnParams({ followupRun })); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun })); expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary run", { provider: "openai", @@ -673,8 +673,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), sessionKey, activeSessionStore, @@ -744,8 +744,8 @@ describe("runAgentTurnWithFallback: primary probe routing", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), sessionKey, activeSessionStore, diff --git a/src/auto-reply/reply/agent-runner-execution-progress.test.ts b/src/auto-reply/reply/agent-runner-execution-progress.test.ts index 028af68742c9..655b28640f8a 100644 --- a/src/auto-reply/reply/agent-runner-execution-progress.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-progress.test.ts @@ -3,7 +3,7 @@ import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, requireRecord, @@ -19,7 +19,7 @@ import type { const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: lifecycle progress", () => { +describe("executeAgentTurn: lifecycle progress", () => { it("forwards item lifecycle events to reply options", async () => { const onItemEvent = vi.fn(); state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { @@ -38,10 +38,10 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); const typingSignals = createMockTypingSignaler(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -110,8 +110,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onItemEvent, @@ -161,8 +161,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onItemEvent, @@ -237,8 +237,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onItemEvent, onToolStart } satisfies GetReplyOptions, }), @@ -272,8 +272,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onToolStart, @@ -317,8 +317,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { onToolStart, @@ -373,8 +373,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { preserveProgressCallbackStartOrder: true, @@ -421,9 +421,9 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); const typingSignals = createMockTypingSignaler(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ opts: { preserveProgressCallbackStartOrder: true, @@ -454,8 +454,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -499,8 +499,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -579,8 +579,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -633,8 +633,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { throw new Error("rebound failure"); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -694,8 +694,8 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -747,11 +747,11 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { meta: {}, })); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "ok do it", followupRun, sessionCtx: { @@ -807,11 +807,11 @@ describe("runAgentTurnWithFallback: lifecycle progress", () => { meta: {}, })); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "explain in detail what changed", followupRun, sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts index 3cfa5fe49127..ba2554a56118 100644 --- a/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-provider-failures.test.ts @@ -10,7 +10,7 @@ import { PROVIDER_INTERNAL_ERROR_USER_MESSAGE, setupAgentRunnerExecutionTestState, GENERIC_RUN_FAILURE_TEXT, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createMockReplyOperation, @@ -52,7 +52,7 @@ function createOpenAiServiceUnavailableError() { }); } -describe("runAgentTurnWithFallback: provider failures", () => { +describe("executeAgentTurn: provider failures", () => { it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( "keeps raw runner failure boilerplate out of $label chats", async (testCase) => { @@ -60,8 +60,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { new Error("openai/gpt-5.5 ended with an incomplete terminal response"), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -90,8 +90,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: { @@ -132,8 +132,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: { @@ -163,8 +163,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { const followupRun = createFollowupRun(); followupRun.run.config = {}; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: createNonDirectFailureSessionCtx(testCase), @@ -185,8 +185,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { new Error('No API key found for provider "openai"'), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -208,8 +208,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { throw createOpenAiServiceUnavailableError(); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "discord", @@ -245,8 +245,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const resultPromise = runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(NON_DIRECT_FAILURE_SURFACE_CASES[0]), }), @@ -265,8 +265,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { vi.useFakeTimers(); state.runEmbeddedAgentMock.mockRejectedValue(createOpenAiServiceUnavailableError()); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const resultPromise = runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(NON_DIRECT_FAILURE_SURFACE_CASES[1]), }), @@ -298,8 +298,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -320,8 +320,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { async (testCase) => { state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded")); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -351,8 +351,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -422,11 +422,11 @@ describe("runAgentTurnWithFallback: provider failures", () => { it.each(NON_DIRECT_FAILURE_SURFACE_CASES)( "surfaces overloaded fallback copy in $label chats", async (testCase) => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); state.runEmbeddedAgentMock.mockRejectedValue(new Error("model is overloaded")); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -445,7 +445,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { ); it("retries fallback-wide overloads turn-locally and sends one delayed status notice", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); for (let attempt = 0; attempt < 4; attempt += 1) { state.runWithModelFallbackMock.mockRejectedValueOnce(createOverloadSummaryError()); @@ -456,7 +456,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); const onBlockReply = vi.fn(); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onBlockReply } }), ); await vi.advanceTimersByTimeAsync(29_999); @@ -488,8 +488,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { throw new Error("model is overloaded"); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(result.kind).toBe("final"); @@ -518,8 +518,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(result.kind).toBe("final"); @@ -547,8 +547,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(1); expect(result.kind).toBe("final"); @@ -576,8 +576,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); const onBlockReply = vi.fn(); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const resultPromise = runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onBlockReply } }), ); await vi.advanceTimersByTimeAsync(30_000); @@ -590,7 +590,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { ); it("sends the delayed overload notice while a retry provider call is still running", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); let resolveRetry!: (value: unknown) => void; const retryResult = new Promise((resolve) => { @@ -601,7 +601,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { .mockImplementationOnce(() => retryResult); const onBlockReply = vi.fn((..._args: unknown[]) => new Promise(() => {})); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onBlockReply } }), ); await vi.advanceTimersByTimeAsync(29_999); @@ -624,7 +624,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); it("does not block retry when a slow first overload makes the status notice immediately due", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); let rejectInitial!: (error: unknown) => void; const initialResult = new Promise((_resolve, reject) => { @@ -640,7 +640,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); const onBlockReply = vi.fn((..._args: unknown[]) => new Promise(() => {})); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onBlockReply } }), ); await vi.advanceTimersByTimeAsync(30_000); @@ -653,14 +653,14 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); it("interrupts overload backoff on abort and cancels the pending status notice", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); state.runEmbeddedAgentMock.mockRejectedValue(new Error("model is overloaded")); const abortController = new AbortController(); const { replyOperation } = createMockReplyOperation({ abortSignal: abortController.signal }); const onBlockReply = vi.fn(); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { onBlockReply }, replyOperation, @@ -685,7 +685,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); it("interrupts the transient HTTP retry backoff on abort", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); state.runEmbeddedAgentMock.mockRejectedValue( new FailoverError("provider request timed out", { @@ -697,9 +697,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { const abortController = new AbortController(); const { replyOperation } = createMockReplyOperation({ abortSignal: abortController.signal }); - const resultPromise = runAgentTurnWithFallback( - createMinimalRunAgentTurnParams({ replyOperation }), - ); + const resultPromise = executeAgentTurn(createMinimalRunAgentTurnParams({ replyOperation })); await vi.advanceTimersByTimeAsync(0); abortController.abort(); await expect(resultPromise).resolves.toMatchObject({ @@ -711,7 +709,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); it("cancels the overload notice immediately when a slow retrying turn is aborted", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); let resolveRetry!: (value: unknown) => void; const retryResult = new Promise((resolve) => { @@ -723,7 +721,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { const abortController = new AbortController(); const onBlockReply = vi.fn(); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ opts: { abortSignal: abortController.signal, onBlockReply }, }), @@ -745,7 +743,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }); it("surfaces typed overloaded failures without rate-limit cooldown copy", async () => { - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); vi.useFakeTimers(); state.runEmbeddedAgentMock.mockRejectedValue( new FailoverError("529 Please try again", { @@ -756,7 +754,7 @@ describe("runAgentTurnWithFallback: provider failures", () => { }), ); - const resultPromise = runAgentTurnWithFallback( + const resultPromise = executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(NON_DIRECT_FAILURE_SURFACE_CASES[0]), }), @@ -787,8 +785,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }, }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, sessionCtx: { @@ -815,8 +813,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { new Error("openai/gpt-5.5 ended with an incomplete terminal response"), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "discord", @@ -838,8 +836,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { new Error("openai/gpt-5.5 ended with an incomplete terminal response"), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "discord", @@ -865,8 +863,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { Object.assign(error, { status: 429 }); state.runEmbeddedAgentMock.mockRejectedValueOnce(error); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "discord", @@ -897,8 +895,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "telegram", @@ -924,8 +922,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "discord", @@ -953,8 +951,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -981,8 +979,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -999,8 +997,8 @@ describe("runAgentTurnWithFallback: provider failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-results.test.ts b/src/auto-reply/reply/agent-runner-execution-results.test.ts index b1ac8399eb12..ab256aa4b1bd 100644 --- a/src/auto-reply/reply/agent-runner-execution-results.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-results.test.ts @@ -4,7 +4,7 @@ import type { TemplateContext } from "../templating.js"; import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, requireRecord, @@ -22,7 +22,7 @@ import type { const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: result and tool delivery", () => { +describe("executeAgentTurn: result and tool delivery", () => { it("forwards media-only tool results without typing text", async () => { const onToolResult = vi.fn(); state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => { @@ -30,10 +30,10 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); const typingSignals = createMockTypingSignaler(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -87,8 +87,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ sessionCtx: createNonDirectFailureSessionCtx(testCase), }), @@ -112,12 +112,12 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { new Error("Selected model is at capacity. Please try a different model."), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.5"; - const resultPromise = runAgentTurnWithFallback({ + const resultPromise = executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -192,8 +192,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams({ followupRun })); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams({ followupRun })); expect(result.kind).toBe("success"); if (result.kind === "success") { @@ -223,8 +223,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("success"); }); @@ -263,8 +263,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback( + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn( createMinimalRunAgentTurnParams({ followupRun, opts: { onBlockReply: vi.fn() } satisfies GetReplyOptions, @@ -307,8 +307,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), blockReplyPipeline, blockStreamingEnabled: true, @@ -340,8 +340,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("success"); }); @@ -380,8 +380,8 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), activeSessionStore, getActiveSessionEntry: () => sessionEntry, @@ -399,10 +399,10 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); const typingSignals = createMockTypingSignaler(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -448,9 +448,9 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -495,9 +495,9 @@ describe("runAgentTurnWithFallback: result and tool delivery", () => { return { payloads: [{ text: "final" }], meta: {} }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const pendingToolTasks = new Set>(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts index 330fdb03c220..259a6a725276 100644 --- a/src/auto-reply/reply/agent-runner-execution-runtime.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-runtime.test.ts @@ -4,7 +4,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, requireRecord, @@ -16,7 +16,7 @@ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: runtime selection", () => { +describe("executeAgentTurn: runtime selection", () => { it("resolves CLI messageProvider from the live session surface when no origin channel is set", async () => { state.isCliProviderMock.mockReturnValue(true); state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => ({ @@ -30,13 +30,13 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "codex-cli"; followupRun.run.model = "gpt-5.4"; followupRun.run.messageProvider = "stale-provider"; - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -93,7 +93,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "anthropic"; followupRun.run.model = "claude-opus-4-7"; @@ -105,7 +105,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { }, }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), getActiveSessionEntry: () => ({ @@ -138,12 +138,12 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), getActiveSessionEntry: () => ({ @@ -174,7 +174,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "anthropic"; followupRun.run.model = "claude-opus-4-6"; @@ -188,7 +188,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { }, }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), isHeartbeat: true, getActiveSessionEntry: () => @@ -232,13 +232,13 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; followupRun.run.config = {}; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), getActiveSessionEntry: () => ({ @@ -272,7 +272,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); followupRun.run.provider = "openai"; followupRun.run.model = "gpt-5.4"; @@ -284,7 +284,7 @@ describe("runAgentTurnWithFallback: runtime selection", () => { }, }; - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ followupRun }), getActiveSessionEntry: () => ({ diff --git a/src/auto-reply/reply/agent-runner-execution-state.test.ts b/src/auto-reply/reply/agent-runner-execution-state.test.ts index 4c297a69d8e2..aed9f9d6cd75 100644 --- a/src/auto-reply/reply/agent-runner-execution-state.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-state.test.ts @@ -4,7 +4,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { TemplateContext } from "../templating.js"; import { setupAgentRunnerExecutionTestState, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, expectMockCallArgFields, @@ -14,7 +14,7 @@ import type { FallbackRunnerParams } from "./agent-runner-execution.test-support const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: session state", () => { +describe("executeAgentTurn: session state", () => { it("restarts the active prompt when a live model switch is requested", async () => { let fallbackInvocation = 0; state.runWithModelFallbackMock.mockImplementation( @@ -51,9 +51,9 @@ describe("runAgentTurnWithFallback: session state", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -108,9 +108,9 @@ describe("runAgentTurnWithFallback: session state", () => { }); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -192,9 +192,9 @@ describe("runAgentTurnWithFallback: session state", () => { }; }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); + const executeAgentTurn = await getExecuteAgentTurnForTest(); const followupRun = createFollowupRun(); - const result = await runAgentTurnWithFallback({ + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -250,8 +250,8 @@ describe("runAgentTurnWithFallback: session state", () => { throw new Error("fallback failed"); }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -312,8 +312,8 @@ describe("runAgentTurnWithFallback: session state", () => { }; const sessionStore = { main: sessionEntry }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -388,8 +388,8 @@ describe("runAgentTurnWithFallback: session state", () => { }; const sessionStore = { main: sessionEntry }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -451,8 +451,8 @@ describe("runAgentTurnWithFallback: session state", () => { }; const sessionStore = { main: sessionEntry }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -517,8 +517,8 @@ describe("runAgentTurnWithFallback: session state", () => { }; const sessionStore = { main: sessionEntry }; - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun, sessionCtx: { @@ -582,8 +582,8 @@ describe("runAgentTurnWithFallback: session state", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(3); expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary candidate", { @@ -614,8 +614,8 @@ describe("runAgentTurnWithFallback: session state", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runCliAgentMock).toHaveBeenCalledOnce(); expect(state.runEmbeddedAgentMock).toHaveBeenCalledOnce(); @@ -653,8 +653,8 @@ describe("runAgentTurnWithFallback: session state", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(2); expectMockCallArgFields(state.runEmbeddedAgentMock, 0, "primary candidate", { diff --git a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts index b3da62e68399..2a6008ce4fca 100644 --- a/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts +++ b/src/auto-reply/reply/agent-runner-execution-terminal-failures.test.ts @@ -9,7 +9,7 @@ import type { GetReplyOptions } from "../types.js"; import { setupAgentRunnerExecutionTestState, GENERIC_RUN_FAILURE_TEXT, - getRunAgentTurnWithFallback, + getExecuteAgentTurnForTest, createMockTypingSignaler, createFollowupRun, createMockReplyOperation, @@ -23,7 +23,7 @@ import { buildKnownAgentRunFailureReplyPayload } from "./agent-runner-failure-re const state = setupAgentRunnerExecutionTestState(); -describe("runAgentTurnWithFallback: terminal failures", () => { +describe("executeAgentTurn: terminal failures", () => { it("surfaces billing guidance for mixed-cause fallback exhaustion", async () => { state.runWithModelFallbackMock.mockRejectedValueOnce( Object.assign( @@ -41,8 +41,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -92,8 +92,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -131,8 +131,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { "You've reached your Codex subscription usage limit. Codex did not return a reset time for this limit. Run /codex account for current usage details."; state.runWithModelFallbackMock.mockRejectedValueOnce(new Error(codexMessage)); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -191,8 +191,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -238,8 +238,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -291,8 +291,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -339,8 +339,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { Object.assign(new Error("aborted"), { name: "AbortError" }), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -394,8 +394,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { meta: {}, }); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -444,8 +444,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { new Error("INVALID_ARGUMENT: some other failure"), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { @@ -508,8 +508,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback(createMinimalRunAgentTurnParams()); + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn(createMinimalRunAgentTurnParams()); expect(result.kind).toBe("final"); if (result.kind === "final") { @@ -524,8 +524,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { new Error('Command lane "main" task timed out after 120000ms'), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), isHeartbeat: true, }); @@ -567,8 +567,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { async ({ rejection, mode, routingSubstring }) => { state.runWithModelFallbackMock.mockRejectedValueOnce(rejection); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), }); @@ -638,8 +638,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { async ({ rejection, expected }) => { state.runWithModelFallbackMock.mockRejectedValueOnce(rejection); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams(), }); @@ -660,8 +660,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { ), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ ...createMinimalRunAgentTurnParams({ sessionCtx: { Provider: "telegram", @@ -688,8 +688,8 @@ describe("runAgentTurnWithFallback: terminal failures", () => { new Error("INVALID_ARGUMENT: some other failure"), ); - const runAgentTurnWithFallback = await getRunAgentTurnWithFallback(); - const result = await runAgentTurnWithFallback({ + const executeAgentTurn = await getExecuteAgentTurnForTest(); + const result = await executeAgentTurn({ commandBody: "hello", followupRun: createFollowupRun(), sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner-execution.test-support.ts b/src/auto-reply/reply/agent-runner-execution.test-support.ts index eb53f5d04c88..83792e7996df 100644 --- a/src/auto-reply/reply/agent-runner-execution.test-support.ts +++ b/src/auto-reply/reply/agent-runner-execution.test-support.ts @@ -253,8 +253,32 @@ vi.mock("./reply-media-paths.runtime.js", () => ({ createReplyMediaPathNormalizer: () => (payload: unknown) => payload, })); -export async function getRunAgentTurnWithFallback() { - return (await import("./agent-runner-execution.js")).runAgentTurnWithFallback; +export async function getExecuteAgentTurnForTest() { + const execute = (await import("./agent-runner-execution.js")).executeAgentTurn; + return async (...args: Parameters) => { + const execution = await execute(...args); + const outcome = execution.outcome; + if (outcome.kind === "settled") { + return { + kind: "success" as const, + runId: execution.runId, + runResult: outcome.result, + fallbackProvider: outcome.resolved.provider, + fallbackModel: outcome.resolved.model, + ...(outcome.fallback.exhausted ? { fallbackExhausted: true as const } : {}), + fallbackAttempts: outcome.fallback.attempts, + didLogHeartbeatStrip: outcome.didLogHeartbeatStrip, + autoCompactionCount: outcome.autoCompactionCount, + directlySentBlockKeys: outcome.directlySentBlockKeys, + directlySentBlockPayloads: outcome.directlySentBlockPayloads, + terminalFailurePayload: outcome.terminalFailurePayload, + }; + } + if (outcome.kind === "rejected") { + return { kind: "final" as const, payload: outcome.payload }; + } + return { kind: "final" as const, payload: { text: "NO_REPLY" } }; + }; } export type FallbackRunnerParams = { diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts index de7a7c31ed9b..1de85494c1b3 100644 --- a/src/auto-reply/reply/agent-runner-execution.ts +++ b/src/auto-reply/reply/agent-runner-execution.ts @@ -17,6 +17,7 @@ import type { RunEmbeddedAgentParams } from "../../agents/embedded-agent-runner/ import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js"; import { leaseMcpAppModelContextForTurn } from "../../agents/mcp-app-model-context.js"; +import { isAgentRunRestartAbortReason } from "../../agents/run-termination.js"; import { createAgentPatchedSessionModelRunGuard } from "../../agents/session-model-auto-revert.js"; import type { SessionEntry } from "../../config/sessions.js"; import { logVerbose } from "../../globals.js"; @@ -43,7 +44,8 @@ import { type OverloadRetryState, } from "./agent-runner-error-handler.js"; import type { - AgentRunLoopResult, + AgentTurnExecutionResult, + AgentTurnInternalResult, AgentTurnParams, RuntimeFallbackAttempt, } from "./agent-runner-execution.types.js"; @@ -64,6 +66,10 @@ import { resolveCurrentTurnImages } from "./current-turn-images.js"; import type { FollowupRun } from "./queue.js"; import type { ReplyMediaContext } from "./reply-media-paths.js"; import { createReplyMediaContext } from "./reply-media-paths.runtime.js"; +import { + isReplyOperationRestartAbort, + isReplyOperationUserAbort, +} from "./reply-operation-abort.js"; import { isReplyProfilerEnabled } from "./reply-timing-tracker.js"; function resolveRunStartupPhase( @@ -92,12 +98,12 @@ function resolveRunStartupPhase( return undefined; } -async function runAgentTurnWithFallbackInternalWithRetryState( +async function executeAgentTurnInternalWithRetryState( params: AgentTurnParams, commitTerminalOutcome: () => void, overloadRetryState: OverloadRetryState, commitMcpAppModelContext: () => void, -): Promise { +): Promise { const heartbeatState = { didLogStrip: false }; let autoCompactionCount = 0; // Track payloads sent directly (not via pipeline) during tool flush to avoid duplicates. @@ -315,7 +321,13 @@ async function runAgentTurnWithFallbackInternalWithRetryState( lifecycleGeneration = fallbackCycleState.lifecycleGeneration; autoCompactionCount = fallbackCycleState.autoCompactionCount; if (cycle.kind === "final") { - return cycle; + return { + ...cycle, + resolved: { + provider: fallbackCycleState.attemptedRuntimeProvider, + model: fallbackCycleState.attemptedRuntimeModel, + }, + }; } runResult = cycle.runResult; fallbackProvider = cycle.fallbackProvider; @@ -342,7 +354,13 @@ async function runAgentTurnWithFallbackInternalWithRetryState( modelPatch, }); if (action.kind === "final") { - return action; + return { + ...action, + resolved: { + provider: fallbackCycleState.attemptedRuntimeProvider, + model: fallbackCycleState.attemptedRuntimeModel, + }, + }; } if (action.liveModelSwitchError) { const switchError = action.liveModelSwitchError; @@ -371,6 +389,7 @@ async function runAgentTurnWithFallbackInternalWithRetryState( params.replyOperation?.fail("run_failed", finalEmbeddedError); return { kind: "final", + resolved: { provider: fallbackProvider, model: fallbackModel }, payload: markAgentRunFailureReplyPayload({ text: "⚠️ Context overflow — this conversation is too large for the model. Use /new to start a fresh session.", }), @@ -434,9 +453,8 @@ async function runAgentTurnWithFallbackInternalWithRetryState( : undefined; return { - kind: "success", - runId, - runResult, + kind: "completed", + result: runResult, fallbackProvider, fallbackModel, ...(fallbackExhausted ? { fallbackExhausted: true as const } : {}), @@ -451,11 +469,11 @@ async function runAgentTurnWithFallbackInternalWithRetryState( }; } -async function runAgentTurnWithFallbackInternal( +async function executeAgentTurnInternal( params: AgentTurnParams, commitTerminalOutcome: () => void, commitMcpAppModelContext: () => void, -): Promise { +): Promise { const overloadRetryState: OverloadRetryState = { retryCount: 0, turnStartedAtMs: Date.now(), @@ -464,7 +482,7 @@ async function runAgentTurnWithFallbackInternal( completed: false, }; try { - return await runAgentTurnWithFallbackInternalWithRetryState( + return await executeAgentTurnInternalWithRetryState( params, commitTerminalOutcome, overloadRetryState, @@ -475,51 +493,118 @@ async function runAgentTurnWithFallbackInternal( } } -/** Runs the agent turn with provider/model fallback, retry, and failure mapping. */ -export async function runAgentTurnWithFallback( - params: AgentTurnParams, -): Promise { +/** Runs the agent turn with provider/model fallback, retry, and closed settlement. */ +export async function executeAgentTurn(params: AgentTurnParams): Promise { + const runId = params.opts?.runId ?? crypto.randomUUID(); + const executionParams = + params.opts?.runId === runId ? params : { ...params, opts: { ...params.opts, runId } }; // Gateway writes require exact view identity against this bare session runtime; // requester-scoped and combined runtimes cannot cross the App view boundary. - const runtime = params.isHeartbeat + const runtime = executionParams.isHeartbeat ? undefined : peekSessionMcpRuntime({ - sessionId: params.followupRun.run.sessionId, - sessionKey: params.sessionKey ?? params.followupRun.run.sessionKey, + sessionId: executionParams.followupRun.run.sessionId, + sessionKey: executionParams.sessionKey ?? executionParams.followupRun.run.sessionKey, }); const modelContextLease = runtime ? leaseMcpAppModelContextForTurn({ runtime, - prompt: params.commandBody, - transcriptPrompt: params.transcriptCommandBody, + prompt: executionParams.commandBody, + transcriptPrompt: executionParams.transcriptCommandBody, }) : undefined; const turnParams = modelContextLease ? { - ...params, + ...executionParams, commandBody: modelContextLease.prompt, transcriptCommandBody: modelContextLease.transcriptPrompt, } - : params; + : executionParams; let terminalOutcomeCommitted = false; + // Callers invoke this only inside the guarded execution below, including its + // inner finally, so restart errors from freezeAbort reach the outer catch. const commitTerminalOutcome = () => { if (terminalOutcomeCommitted) { return; } terminalOutcomeCommitted = true; - params.replyOperation?.freezeAbort(); + executionParams.replyOperation?.freezeAbort(); }; - const lifecycleGeneration = captureAgentRunLifecycleGeneration(params.opts?.runId ?? ""); - return await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { - try { - return await runAgentTurnWithFallbackInternal( - turnParams, - commitTerminalOutcome, - modelContextLease?.commit ?? (() => undefined), - ); - } finally { - modelContextLease?.rollback(); - commitTerminalOutcome(); + const lifecycleGeneration = captureAgentRunLifecycleGeneration(runId); + try { + const internal = await withAgentRunLifecycleGeneration(lifecycleGeneration, async () => { + try { + return await executeAgentTurnInternal( + turnParams, + commitTerminalOutcome, + modelContextLease?.commit ?? (() => undefined), + ); + } finally { + modelContextLease?.rollback(); + commitTerminalOutcome(); + } + }); + if (internal.kind === "final") { + if (isReplyOperationRestartAbort(executionParams.replyOperation)) { + return { runId, outcome: { kind: "aborted", reason: "restart" } }; + } + if (isReplyOperationUserAbort(executionParams.replyOperation)) { + return { runId, outcome: { kind: "aborted", reason: "user" } }; + } + return { + runId, + outcome: { + kind: "rejected", + payload: internal.payload, + resolved: internal.resolved, + }, + }; } - }); + const abortReason = isReplyOperationRestartAbort(executionParams.replyOperation) + ? "restart" + : isReplyOperationUserAbort(executionParams.replyOperation) + ? "user" + : undefined; + const provider = + internal.fallbackProvider ?? + internal.result.meta?.agentMeta?.provider ?? + executionParams.followupRun.run.provider; + const model = + internal.fallbackModel ?? + internal.result.meta?.agentMeta?.model ?? + executionParams.followupRun.run.model; + return { + runId, + outcome: { + kind: "settled", + status: internal.terminalFailurePayload ? "failed" : "ok", + ...(abortReason ? { abortReason } : {}), + result: internal.result, + resolved: { provider, model }, + fallback: { + exhausted: internal.fallbackExhausted === true, + attempts: internal.fallbackAttempts, + }, + autoCompactionCount: internal.autoCompactionCount, + didLogHeartbeatStrip: internal.didLogHeartbeatStrip, + directlySentBlockKeys: internal.directlySentBlockKeys, + directlySentBlockPayloads: internal.directlySentBlockPayloads, + terminalFailurePayload: internal.terminalFailurePayload, + }, + }; + } catch (error) { + if ( + isReplyOperationRestartAbort(executionParams.replyOperation) || + isAgentRunRestartAbortReason(error) + ) { + if (executionParams.replyOperation && !executionParams.replyOperation.result) { + executionParams.replyOperation.complete(); + } + return { runId, outcome: { kind: "aborted", reason: "restart" } }; + } + if (isReplyOperationUserAbort(executionParams.replyOperation)) { + return { runId, outcome: { kind: "aborted", reason: "user" } }; + } + throw error; + } } diff --git a/src/auto-reply/reply/agent-runner-execution.types.ts b/src/auto-reply/reply/agent-runner-execution.types.ts index 90447b58c332..683982ef41bb 100644 --- a/src/auto-reply/reply/agent-runner-execution.types.ts +++ b/src/auto-reply/reply/agent-runner-execution.types.ts @@ -20,12 +20,11 @@ export type RuntimeFallbackAttempt = { code?: string; }; -/** Result of running an agent turn through fallback/retry handling. */ -export type AgentRunLoopResult = +/** Internal fallback-cycle result before caller-facing settlement projection. */ +export type AgentTurnInternalResult = | { - kind: "success"; - runId: string; - runResult: Awaited>; + kind: "completed"; + result: Awaited>; fallbackProvider?: string; fallbackModel?: string; fallbackExhausted?: true; @@ -39,7 +38,38 @@ export type AgentRunLoopResult = /** Prepared terminal failure, appended only after delivery evidence settles. */ terminalFailurePayload?: ReplyPayload; } - | { kind: "final"; payload: ReplyPayload }; + | { + kind: "final"; + payload: ReplyPayload; + resolved?: { provider: string; model: string }; + }; + +export type SettledAgentTurn = { + kind: "settled"; + status: "ok" | "failed"; + abortReason?: "user" | "restart"; + result: Awaited>; + resolved: { provider: string; model: string }; + fallback: { exhausted: boolean; attempts: RuntimeFallbackAttempt[] }; + autoCompactionCount: number; + didLogHeartbeatStrip: boolean; + directlySentBlockKeys?: Set; + directlySentBlockPayloads?: ReplyPayload[]; + terminalFailurePayload?: ReplyPayload; +}; + +/** Closed result shared by foreground and queued agent-turn callers. */ +export type AgentTurnExecutionResult = { + runId: string; + outcome: + | SettledAgentTurn + | { kind: "aborted"; reason: "user" | "restart" } + | { + kind: "rejected"; + payload: ReplyPayload; + resolved?: { provider: string; model: string }; + }; +}; /** Inputs shared by direct and queued agent-turn execution. */ export type AgentTurnParams = { diff --git a/src/auto-reply/reply/agent-runner-fallback-cycle.types.ts b/src/auto-reply/reply/agent-runner-fallback-cycle.types.ts index 26a8ce643796..6414e6fd344c 100644 --- a/src/auto-reply/reply/agent-runner-fallback-cycle.types.ts +++ b/src/auto-reply/reply/agent-runner-fallback-cycle.types.ts @@ -3,7 +3,7 @@ import type { SessionEntry } from "../../config/sessions.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import type { AgentLifecycleTerminalBackstop } from "./agent-lifecycle-terminal.js"; import type { - AgentRunLoopResult, + AgentTurnInternalResult, AgentTurnParams, EmbeddedAgentRunResult, RuntimeFallbackAttempt, @@ -37,7 +37,7 @@ type CompletedFallbackCycle = { export type AgentFallbackCycleResult = | CompletedFallbackCycle - | Extract; + | Extract; type AgentFallbackModelPatch = { captureFallbackFailure: (attempts: RuntimeFallbackAttempt[]) => boolean | undefined; diff --git a/src/auto-reply/reply/agent-runner-result-accounting.ts b/src/auto-reply/reply/agent-runner-result-accounting.ts index a5823b76e81c..6e9d5cf6cfc3 100644 --- a/src/auto-reply/reply/agent-runner-result-accounting.ts +++ b/src/auto-reply/reply/agent-runner-result-accounting.ts @@ -8,11 +8,17 @@ import { updateSessionEntry } from "../../config/sessions/session-accessor.js"; import { logVerbose } from "../../globals.js"; import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; import { resolveFallbackTransition } from "../fallback-state.js"; +import { normalizeVerboseLevel } from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; import { resolveConfiguredFallbackModel } from "./agent-runner-core.js"; import type { FinalizeReplyAgentRunInput } from "./agent-runner-result.types.js"; +import type { AdmittedFollowupTurn, FollowupRunnerParams } from "./followup-turn-admission.js"; +import type { FollowupExecutionResult } from "./followup-turn-execution.js"; import { drainPendingToolTasks } from "./pending-tool-task-drain.js"; +import { refreshQueuedFollowupSession } from "./queue.js"; import { buildReplyUsageState, recordReplyUsageState } from "./reply-usage-state.js"; import { persistRunSessionUsage } from "./session-run-accounting.js"; +import { incrementRunCompactionCount } from "./session-run-accounting.js"; type AgentTurnAccountingContext = Pick< FinalizeReplyAgentRunInput, @@ -27,7 +33,8 @@ type AgentTurnAccountingContext = Pick< | "pendingToolTasks" | "preflightCompactionApplied" | "resolvedVerboseLevel" - | "runOutcome" + | "execution" + | "runId" | "runStartedAt" | "sessionCtx" | "sessionKey" @@ -47,7 +54,8 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { pendingToolTasks, preflightCompactionApplied, resolvedVerboseLevel, - runOutcome, + execution, + runId, runStartedAt, sessionKey, sessionCtx, @@ -56,19 +64,15 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { } = context; let { activeSessionEntry } = context; - const { - runId, - runResult, - fallbackProvider, - fallbackModel, - fallbackExhausted, - fallbackAttempts, - directlySentBlockKeys, - directlySentBlockPayloads, - terminalFailurePayload, - } = runOutcome; - const { autoCompactionCount } = runOutcome; - const { didLogHeartbeatStrip } = runOutcome; + const runResult = execution.result; + const fallbackProvider = execution.resolved.provider; + const fallbackModel = execution.resolved.model; + const fallbackExhausted = execution.fallback.exhausted; + const fallbackAttempts = execution.fallback.attempts; + const directlySentBlockKeys = execution.directlySentBlockKeys; + const directlySentBlockPayloads = execution.directlySentBlockPayloads; + const terminalFailurePayload = execution.terminalFailurePayload; + const { autoCompactionCount, didLogHeartbeatStrip } = execution; if ( shouldInjectGroupIntro && @@ -300,3 +304,94 @@ export async function accountAgentTurn(context: AgentTurnAccountingContext) { verboseEnabled, }; } + +export type AccountedAgentTurn = Awaited>; + +/** Applies common accounting plus the queue/session projection owned by follow-up turns. */ +export async function accountFollowupTurn(params: { + turn: AdmittedFollowupTurn; + defaults: FollowupRunnerParams; + execution: FollowupExecutionResult; +}) { + const settled = params.execution.execution.outcome; + if (settled.kind !== "settled") { + return undefined; + } + const { turn, defaults, execution } = params; + const sessionKey = turn.session.kind === "session" ? turn.session.key : undefined; + const accounting = await accountAgentTurn({ + activeSessionEntry: turn.session.current(), + activeSessionStore: turn.sessionStore, + agentCfgContextTokens: defaults.agentCfgContextTokens, + blockReplyPipeline: null, + cfg: turn.config, + defaultModel: defaults.defaultModel, + followupRun: turn.queued, + isHeartbeat: defaults.opts?.isHeartbeat === true, + pendingToolTasks: execution.pendingToolTasks, + preflightCompactionApplied: turn.preflightCompactionApplied, + resolvedVerboseLevel: + normalizeVerboseLevel(turn.session.current()?.verboseLevel ?? turn.queued.run.verboseLevel) ?? + "off", + execution: settled, + runId: execution.execution.runId, + runStartedAt: execution.runStartedAt, + sessionCtx: execution.sessionCtx, + sessionKey, + shouldInjectGroupIntro: false, + storePath: turn.session.kind === "session" ? turn.session.storePath : undefined, + }); + turn.session.publish(accounting.activeSessionEntry); + const queueKey = turn.queued.run.sessionKey ?? defaults.sessionKey ?? sessionKey; + if ( + queueKey && + accounting.fallbackTransition.stateChanged && + !accounting.fallbackExhausted && + !accounting.preserveUserFacingSessionState + ) { + const entry = turn.session.current(); + refreshQueuedFollowupSession({ + key: queueKey, + previousSessionId: turn.queued.run.sessionId, + nextSessionId: entry?.sessionId ?? turn.queued.run.sessionId, + nextSessionFile: entry?.sessionFile, + nextProvider: accounting.providerUsed, + nextModel: accounting.modelUsed, + nextModelOverrideSource: entry?.modelOverrideSource, + nextAuthProfileId: entry?.authProfileOverride, + nextAuthProfileIdSource: entry?.authProfileOverrideSource, + }); + } + let compactionNotice: ReplyPayload | undefined; + if (accounting.autoCompactionCount > 0) { + const previousSessionId = turn.queued.run.sessionId; + const count = await incrementRunCompactionCount({ + cfg: turn.config, + sessionEntry: turn.session.current(), + sessionStore: turn.sessionStore, + sessionKey, + storePath: turn.session.kind === "session" ? turn.session.storePath : undefined, + amount: accounting.autoCompactionCount, + compactionTokensAfter: accounting.runResult.meta?.agentMeta?.compactionTokensAfter, + lastCallUsage: accounting.runResult.meta?.agentMeta?.lastCallUsage, + contextTokensUsed: accounting.contextTokensUsed, + newSessionId: accounting.runResult.meta?.agentMeta?.sessionId, + newSessionFile: accounting.runResult.meta?.agentMeta?.sessionFile, + }); + const refreshed = turn.session.current(); + if (refreshed) { + turn.session.publish(refreshed); + refreshQueuedFollowupSession({ + key: queueKey ?? "", + previousSessionId, + nextSessionId: refreshed.sessionId, + nextSessionFile: refreshed.sessionFile, + }); + } + if (accounting.verboseEnabled) { + const suffix = typeof count === "number" ? ` (count ${count})` : ""; + compactionNotice = { text: `🧹 Auto-compaction complete${suffix}.` }; + } + } + return { ...accounting, compactionNotice }; +} diff --git a/src/auto-reply/reply/agent-runner-result-complete.ts b/src/auto-reply/reply/agent-runner-result-complete.ts index 6c57f38c54db..6bff6c89dfd7 100644 --- a/src/auto-reply/reply/agent-runner-result-complete.ts +++ b/src/auto-reply/reply/agent-runner-result-complete.ts @@ -9,6 +9,7 @@ import { enqueueSystemEvent } from "../../infra/system-events.js"; import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; import { DEFAULT_HEARTBEAT_ACK_MAX_CHARS, stripHeartbeatToken } from "../heartbeat.js"; import { setReplyPayloadMetadata } from "../reply-payload.js"; +import { SILENT_REPLY_TOKEN } from "../tokens.js"; import type { ReplyPayload } from "../types.js"; import { buildInlinePluginStatusPayload, @@ -62,6 +63,7 @@ export async function completeReplyAgentRun(input: { activeIsNewSession, activeSessionStore, cfg, + execution, followupRun, isHeartbeat, opts, @@ -148,6 +150,9 @@ export async function completeReplyAgentRun(input: { prefixNotices.push({ text: `🧹 Auto-compaction complete${suffix}.` }); } } + if (execution.abortReason) { + return returnWithQueuedFollowupDrain({ text: SILENT_REPLY_TOKEN }); + } const prefixPayloads = [...prefixNotices]; const isHookBlockedRun = runResult.meta?.error?.kind === "hook_block"; const rawUserText = isHookBlockedRun diff --git a/src/auto-reply/reply/agent-runner-result.types.ts b/src/auto-reply/reply/agent-runner-result.types.ts index c165b8e166e7..f9c8853adc6a 100644 --- a/src/auto-reply/reply/agent-runner-result.types.ts +++ b/src/auto-reply/reply/agent-runner-result.types.ts @@ -2,7 +2,7 @@ import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; import type { OriginatingChannelType } from "../templating.js"; import type { RunReplyAgentParams } from "./agent-runner-core.js"; -import type { AgentRunLoopResult } from "./agent-runner-execution.types.js"; +import type { SettledAgentTurn } from "./agent-runner-execution.types.js"; import type { BlockReplyPipeline } from "./block-reply-pipeline.js"; import type { FollowupRun } from "./queue.js"; import type { ReplyMediaContext } from "./reply-media-paths.js"; @@ -11,8 +11,6 @@ import type { resolveReplyToMode } from "./reply-threading.js"; import type { resolveRoutedDeliveryThreadId } from "./routed-delivery-thread.js"; import type { TypingSignaler } from "./typing-mode.js"; -type SuccessfulAgentRun = Extract; - export type FinalizeReplyAgentRunInput = Pick< RunReplyAgentParams, | "agentCfgContextTokens" @@ -47,7 +45,8 @@ export type FinalizeReplyAgentRunInput = Pick< replyToMode: ReturnType; returnWithQueuedFollowupDrain: (value: T) => T; runFollowupTurn: (queued: FollowupRun) => Promise; - runOutcome: SuccessfulAgentRun; + execution: SettledAgentTurn; + runId: string; runStartedAt: number; typingSignals: TypingSignaler; }; diff --git a/src/auto-reply/reply/agent-runner-utils.ts b/src/auto-reply/reply/agent-runner-utils.ts index 1ce50c0f33d1..6f83058dce16 100644 --- a/src/auto-reply/reply/agent-runner-utils.ts +++ b/src/auto-reply/reply/agent-runner-utils.ts @@ -25,7 +25,6 @@ import type { SessionEntry } from "../../config/sessions.js"; import { isReasoningTagProvider } from "../../utils/provider-utils.js"; import type { TemplateContext } from "../templating.js"; import { resolveRunAuthProfile } from "./agent-runner-auth-profile.js"; -export { resolveRunAuthProfile }; import { buildEmbeddedRunBaseParams as buildEmbeddedRunBaseParamsCore } from "./agent-runner-run-params.js"; export { resolveModelFallbackOptions } from "./agent-runner-run-params.js"; import { hasInboundAudio } from "./inbound-media.js"; diff --git a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts index 5946e1953a59..5dbea7b73814 100644 --- a/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.final-media-runreplyagent.test.ts @@ -6,7 +6,7 @@ import type { FollowupRun, QueueSettings } from "./queue.js"; import type { ReplyOperation } from "./reply-run-registry.js"; import { createMockFollowupRun, createMockTypingController } from "./test-helpers.js"; -const runAgentTurnWithFallbackMock = vi.fn(); +const executeAgentTurnMock = vi.fn(); const resolveOutboundAttachmentFromUrlMock = vi.fn(); const enqueueFollowupRunMock = vi.fn(); const refreshQueuedFollowupSessionMock = vi.fn(); @@ -67,7 +67,7 @@ vi.mock("./agent-runner-failure-reply.js", () => ({ })); vi.mock("./agent-runner-execution.js", () => ({ - runAgentTurnWithFallback: (...args: unknown[]) => runAgentTurnWithFallbackMock(...args), + executeAgentTurn: (...args: unknown[]) => executeAgentTurnMock(...args), })); vi.mock("./agent-runner-memory.js", () => ({ @@ -105,8 +105,8 @@ vi.mock("./session-run-accounting.js", () => ({ const { runReplyAgent } = await import("./agent-runner.js"); -type AgentRunLoopResult = Awaited< - ReturnType +type AgentTurnExecutionResult = Awaited< + ReturnType >; function createReplyOperation(): ReplyOperation { @@ -171,13 +171,13 @@ function makeRunReplyAgentParams( describe("runReplyAgent final MEDIA replies", () => { beforeEach(() => { vi.stubEnv("OPENCLAW_TEST_FAST", "1"); - runAgentTurnWithFallbackMock.mockReset(); + executeAgentTurnMock.mockReset(); resolveOutboundAttachmentFromUrlMock.mockReset(); enqueueFollowupRunMock.mockReset(); refreshQueuedFollowupSessionMock.mockReset(); scheduleFollowupDrainMock.mockReset(); - runAgentTurnWithFallbackMock.mockImplementation(async (params: unknown) => { + executeAgentTurnMock.mockImplementation(async (params: unknown) => { const { buildReplyPayloads } = await vi.importActual< typeof import("./agent-runner-payloads.js") >("./agent-runner-payloads.js"); @@ -214,9 +214,9 @@ describe("runReplyAgent final MEDIA replies", () => { throw new Error("expected parsed reply payload"); } return { - kind: "final", - payload, - } satisfies AgentRunLoopResult; + runId: "media-test", + outcome: { kind: "rejected", payload }, + } satisfies AgentTurnExecutionResult; }); resolveOutboundAttachmentFromUrlMock.mockImplementation(async (mediaUrl: string) => ({ path: path.join("/tmp/outbound-media", path.basename(mediaUrl)), @@ -235,7 +235,7 @@ describe("runReplyAgent final MEDIA replies", () => { mediaUrl: "/tmp/outbound-media/generated.png", mediaUrls: ["/tmp/outbound-media/generated.png"], }); - expect(runAgentTurnWithFallbackMock).toHaveBeenCalledOnce(); + expect(executeAgentTurnMock).toHaveBeenCalledOnce(); expect(resolveOutboundAttachmentFromUrlMock).toHaveBeenCalledWith( path.join("/tmp/workspace", "out", "generated.png"), 5 * 1024 * 1024, @@ -251,7 +251,7 @@ describe("runReplyAgent final MEDIA replies", () => { path: path.join("/tmp/outbound-media", `${stagedIndex}-${path.basename(mediaUrl)}`), }; }); - runAgentTurnWithFallbackMock.mockImplementationOnce(async (params: unknown) => { + executeAgentTurnMock.mockImplementationOnce(async (params: unknown) => { const { buildReplyPayloads } = await vi.importActual< typeof import("./agent-runner-payloads.js") >("./agent-runner-payloads.js"); @@ -299,9 +299,9 @@ describe("runReplyAgent final MEDIA replies", () => { throw new Error("expected parsed final payload"); } return { - kind: "final", - payload, - } satisfies AgentRunLoopResult; + runId: "media-test", + outcome: { kind: "rejected", payload }, + } satisfies AgentTurnExecutionResult; }); const result = await runReplyAgent( diff --git a/src/auto-reply/reply/agent-runner.media-paths.test.ts b/src/auto-reply/reply/agent-runner.media-paths.test.ts index 30e7876ba05e..cf5562328d0c 100644 --- a/src/auto-reply/reply/agent-runner.media-paths.test.ts +++ b/src/auto-reply/reply/agent-runner.media-paths.test.ts @@ -247,7 +247,7 @@ vi.mock("../../media/outbound-attachment.js", () => ({ })); // Spy on the .runtime import path used by agent-runner-execution.ts so we can assert -// that the fix prevents a second media context from being created inside runAgentTurnWithFallback. +// that the fix prevents a second media context from being created inside executeAgentTurn. vi.mock("./reply-media-paths.runtime.js", async (importOriginal) => { const mod = await importOriginal(); return { @@ -642,8 +642,8 @@ describe("runReplyAgent media path normalization", () => { sessionCtx: TemplateContext, prompt = "describe this image", ): Promise { - const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js"); - await runAgentTurnWithFallback({ + const { executeAgentTurn } = await import("./agent-runner-execution.js"); + await executeAgentTurn({ commandBody: prompt, followupRun: createMockFollowupRun({ prompt, @@ -685,9 +685,9 @@ describe("runReplyAgent media path normalization", () => { }); } - it("reuses the provided media context inside runAgentTurnWithFallback", async () => { + it("reuses the provided media context inside executeAgentTurn", async () => { // Regression test for openclaw/openclaw#68056. - // runAgentTurnWithFallback must use the caller-provided context so block + // executeAgentTurn must use the caller-provided context so block // replies and final replies can share one media cache. runEmbeddedAgentMock.mockResolvedValue({ payloads: [], @@ -700,7 +700,7 @@ describe("runReplyAgent media path normalization", () => { }, }); - const { runAgentTurnWithFallback } = await import("./agent-runner-execution.js"); + const { executeAgentTurn } = await import("./agent-runner-execution.js"); const followupRun = createMockFollowupRun({ prompt: "generate", run: { @@ -710,7 +710,7 @@ describe("runReplyAgent media path normalization", () => { config: {}, }, }); - await runAgentTurnWithFallback({ + await executeAgentTurn({ commandBody: "generate", followupRun, sessionCtx: { diff --git a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts index d76ae93ddeea..080d90a5d238 100644 --- a/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts +++ b/src/auto-reply/reply/agent-runner.misc.runreplyagent.test.ts @@ -3120,7 +3120,7 @@ describe("runReplyAgent transient HTTP retry", () => { }); describe("runReplyAgent billing error classification", () => { - // Regression guard for the runner-level catch block in runAgentTurnWithFallback. + // Regression guard for the runner-level catch block in executeAgentTurn. // Billing errors from providers like OpenRouter can contain token/size wording that // matches context overflow heuristics. This test verifies the final user-visible // message is the billing-specific one, not the "Context overflow" fallback. diff --git a/src/auto-reply/reply/followup-delivery-payloads.ts b/src/auto-reply/reply/followup-delivery-payloads.ts new file mode 100644 index 000000000000..37b24d3a589e --- /dev/null +++ b/src/auto-reply/reply/followup-delivery-payloads.ts @@ -0,0 +1,142 @@ +import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; +import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; +import type { ReplyToMode } from "../../config/types.base.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { stripHeartbeatToken } from "../heartbeat.js"; +import { + copyReplyPayloadMetadata, + getReplyPayloadMetadata, + setReplyPayloadMetadata, +} from "../reply-payload.js"; +import type { OriginatingChannelType } from "../templating.js"; +import type { ReplyPayload } from "../types.js"; +import { + resolveOriginAccountId, + resolveOriginMessageProvider, + resolveOriginMessageTo, +} from "./origin-routing.js"; +import { + applyReplyThreading, + filterMessagingToolDuplicates, + filterMessagingToolMediaDuplicates, + resolveMessagingToolPayloadDedupe, +} from "./reply-payloads.js"; +import { createReplyDeliveryContext, resolveReplyToMode } from "./reply-threading.js"; + +/** Strips empty/heartbeat payloads, applies threading, and dedupes message-tool sends. */ +export function resolveFollowupDeliveryPayloads(params: { + cfg: OpenClawConfig; + payloads: ReplyPayload[]; + messageProvider?: string; + originatingAccountId?: string; + originatingChannel?: string; + originatingChatType?: string | null; + originatingReplyToMode?: ReplyToMode; + originatingTo?: string; + originatingThreadId?: string | number; + reasoningPayloadsEnabled?: boolean; + commentaryPayloadsEnabled?: boolean; + sentMediaUrls?: string[]; + sentTargets?: MessagingToolSend[]; + sentTexts?: string[]; +}): ReplyPayload[] { + const replyMessageProvider = resolveOriginMessageProvider({ + originatingChannel: params.originatingChannel, + provider: params.messageProvider, + }); + const replyToChannel = replyMessageProvider as OriginatingChannelType | undefined; + const replyToMode = + params.originatingReplyToMode ?? + resolveReplyToMode( + params.cfg, + replyToChannel, + params.originatingAccountId, + params.originatingChatType, + ); + const accountId = resolveOriginAccountId({ + originatingAccountId: params.originatingAccountId, + }); + const replyDelivery = createReplyDeliveryContext(replyToMode, params.originatingChatType); + const replyDeliverySource = replyMessageProvider + ? { + channel: replyMessageProvider, + ...(accountId ? { accountId } : {}), + } + : undefined; + const deliverablePayloads = params.payloads.filter( + (payload) => + !(payload.isReasoning === true && params.reasoningPayloadsEnabled !== true) && + !(payload.isCommentary === true && params.commentaryPayloadsEnabled !== true), + ); + const sanitizedPayloads: ReplyPayload[] = []; + for (const payload of deliverablePayloads) { + const text = payload.text; + const sanitized = + text?.includes("HEARTBEAT_OK") === true + ? copyReplyPayloadMetadata(payload, { + ...payload, + text: stripHeartbeatToken(text, { mode: "message" }).text, + }) + : payload; + // Normalize before callers decide whether the run was empty. Otherwise a + // whitespace-only model payload can suppress the interactive fallback. + if (hasOutboundReplyContent(sanitized, { trimText: true })) { + sanitizedPayloads.push(sanitized); + } + } + const replyTaggedPayloads = applyReplyThreading({ + payloads: sanitizedPayloads, + replyToMode, + replyToChannel, + }).map((payload) => + setReplyPayloadMetadata(payload, { + replyDelivery, + ...(replyDeliverySource ? { replyDeliverySource } : {}), + }), + ); + const sentMediaUrlFallback = params.sentMediaUrls ?? []; + const sentTextFallback = params.sentTexts ?? []; + const originatingTo = resolveOriginMessageTo({ + originatingTo: params.originatingTo, + }); + const dedupedPayloads: ReplyPayload[] = []; + for (const payload of replyTaggedPayloads) { + const decision = resolveMessagingToolPayloadDedupe({ + config: params.cfg, + messageProvider: replyMessageProvider, + messagingToolSentTargets: params.sentTargets, + originatingTo, + originatingThreadId: params.originatingThreadId, + replyToId: payload.replyToId, + replyToIsExplicit: Boolean( + getReplyPayloadMetadata(payload)?.replyToIdExplicit || + payload.replyToTag || + payload.replyToCurrent, + ), + replyDelivery: getReplyPayloadMetadata(payload)?.replyDelivery, + accountId, + }); + if (!decision.shouldDedupePayloads) { + dedupedPayloads.push(payload); + continue; + } + const sentMediaUrls = + decision.matchingRoute && !decision.useGlobalSentMediaUrlEvidenceFallback + ? decision.routeSentMediaUrls + : sentMediaUrlFallback; + const sentTexts = + decision.matchingRoute && !decision.useGlobalSentTextEvidenceFallback + ? decision.routeSentTexts + : sentTextFallback; + const mediaFiltered = filterMessagingToolMediaDuplicates({ + payloads: [payload], + sentMediaUrls, + }); + const textFiltered = filterMessagingToolDuplicates({ + payloads: mediaFiltered, + sentTexts, + }); + dedupedPayloads.push(...textFiltered); + } + return dedupedPayloads; +} diff --git a/src/auto-reply/reply/followup-delivery.test.ts b/src/auto-reply/reply/followup-delivery.test.ts index 072370a919b7..bfe4f23d55fc 100644 --- a/src/auto-reply/reply/followup-delivery.test.ts +++ b/src/auto-reply/reply/followup-delivery.test.ts @@ -2,13 +2,39 @@ import { describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../../config/config.js"; import { getReplyPayloadMetadata, setReplyPayloadMetadata } from "../reply-payload.js"; -import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js"; +import type { ReplyPayload } from "../types.js"; +import type { AgentTurnExecutionResult } from "./agent-runner-execution.types.js"; +import { resolveFollowupDeliveryPayloads } from "./followup-delivery-payloads.js"; +import { deliverFollowupDecision, resolveFollowupDeliveryDecision } from "./followup-delivery.js"; +import type { AdmittedFollowupTurn } from "./followup-turn-admission.js"; + +const deliveryState = vi.hoisted(() => ({ + followupRoute: undefined as { route: "dispatcher" | "origin" | "drop" } | undefined, + routeReply: vi.fn(), + runtimeError: vi.fn(), +})); vi.mock("../../channels/plugins/index.js", () => ({ getChannelPlugin: () => undefined, getLoadedChannelPlugin: () => undefined, })); +vi.mock("../../agents/runtime-plan/build.js", () => ({ + buildAgentRuntimeDeliveryPlan: () => ({ + isSilentPayload: () => false, + resolveFollowupRoute: () => deliveryState.followupRoute, + }), +})); + +vi.mock("../../runtime.js", () => ({ + defaultRuntime: { error: (...args: unknown[]) => deliveryState.runtimeError(...args) }, +})); + +vi.mock("./route-reply.js", () => ({ + isRoutableChannel: (channel: string | undefined) => channel === "discord" || channel === "slack", + routeReply: (...args: unknown[]) => deliveryState.routeReply(...args), +})); + const baseConfig = {} as OpenClawConfig; describe("resolveFollowupDeliveryPayloads", () => { @@ -348,3 +374,503 @@ describe("resolveFollowupDeliveryPayloads", () => { ).toEqual([{ text: "hello world!" }]); }); }); + +function createTurn(overrides: Partial = {}): AdmittedFollowupTurn { + return { + runId: "run-1", + queued: { + prompt: "queued", + enqueuedAt: 1, + originatingChannel: "discord", + originatingTo: "channel:C1", + run: { + agentId: "agent", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + provider: "anthropic", + model: "claude", + messageProvider: "discord", + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + }, + operation: {} as AdmittedFollowupTurn["operation"], + config: {}, + session: { + kind: "session", + key: "main", + current: () => undefined, + publish: () => undefined, + adopt: () => undefined, + }, + sendPolicy: "allow", + preflightCompactionApplied: false, + ...overrides, + }; +} + +function createSettledExecution(finalText = ""): AgentTurnExecutionResult { + return { + runId: "run-1", + outcome: { + kind: "settled", + status: "ok", + result: { + payloads: finalText ? [{ text: finalText }] : [], + meta: { durationMs: 0, finalAssistantVisibleText: finalText }, + }, + resolved: { provider: "anthropic", model: "claude" }, + fallback: { exhausted: false, attempts: [] }, + autoCompactionCount: 0, + didLogHeartbeatStrip: false, + }, + }; +} + +function createAccounting( + payloadArray: ReplyPayload[] = [], + overrides: Record = {}, +) { + return { + payloadArray, + providerUsed: "anthropic", + modelUsed: "claude", + preserveUserFacingSessionState: false, + replyUsageState: {}, + usage: undefined, + terminalFailurePayload: undefined, + ...overrides, + } as never; +} + +describe("resolveFollowupDeliveryDecision", () => { + it("keeps ambient room-event finals silent", () => { + const turn = createTurn({ + queued: { + ...createTurn().queued, + currentInboundEventKind: "room_event", + }, + }); + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution("private room final"), + }), + ).toEqual({ kind: "suppress", reason: "room-event" }); + }); + + it("honors the admission-time send policy before any final projection", () => { + expect( + resolveFollowupDeliveryDecision({ + turn: createTurn({ sendPolicy: "deny" }), + execution: createSettledExecution("blocked"), + }), + ).toEqual({ kind: "suppress", reason: "send-policy" }); + }); + + it("suppresses a settled result whose accepted abort still requires accounting", () => { + const execution = createSettledExecution("late reply"); + if (execution.outcome.kind === "settled") { + execution.outcome.abortReason = "user"; + } + + expect( + resolveFollowupDeliveryDecision({ + turn: createTurn(), + execution, + accounting: createAccounting([{ text: "late reply" }]), + }), + ).toEqual({ kind: "suppress", reason: "aborted" }); + }); + + it("does not leak rejected private text in message-tool-only mode", () => { + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: { + runId: "run-1", + outcome: { kind: "rejected", payload: { text: "private failure detail" } }, + }, + }), + ).toEqual({ kind: "suppress", reason: "message-tool-only" }); + }); + + it("keeps rejected failures silent for internal follow-ups", () => { + const turn = createTurn(); + turn.queued.run.inputProvenance = { kind: "internal_system", sourceTool: "test" }; + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: { + runId: "run-1", + outcome: { kind: "rejected", payload: { text: "internal failure" } }, + }, + }), + ).toEqual({ kind: "suppress", reason: "silent" }); + }); + + it("keeps provenance-less internal-channel failures non-interactive", () => { + const turn = createTurn(); + turn.queued.originatingChannel = "webchat"; + turn.queued.run.messageProvider = "webchat"; + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: { + runId: "run-1", + outcome: { kind: "rejected", payload: { text: "internal failure" } }, + }, + opts: { onBlockReply: vi.fn(async () => {}) }, + }), + ).toEqual({ kind: "suppress", reason: "silent" }); + }); + + it("normalizes rejected failures with the originating delivery context", () => { + const turn = createTurn(); + turn.queued.originatingChatType = "group"; + turn.queued.originatingReplyToMode = "all"; + const payload = setReplyPayloadMetadata( + { text: "visible failure", isError: true }, + { deliverDespiteSourceReplySuppression: true }, + ); + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: { + runId: "run-1", + outcome: { kind: "rejected", payload }, + }, + }); + + expect(decision.kind).toBe("deliver"); + if (decision.kind === "deliver") { + expect(getReplyPayloadMetadata(decision.payloads[0] ?? {})?.replyDelivery).toEqual({ + chatType: "group", + replyToMode: "all", + }); + } + }); + + it("creates one priority retry for a substantive message-tool-only final", () => { + const substantiveFinal = + "This is a substantive private answer that should have used the message tool. It has a second sentence so recovery is required."; + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(substantiveFinal), + accounting: createAccounting(), + }); + + expect(decision).toMatchObject({ + kind: "retry-source-delivery", + run: { strandedReplyRetry: true, disableCollectBatching: true }, + }); + }); + + it("delivers explicitly allowed payloads before considering stranded recovery", () => { + const substantiveFinal = + "This is a substantive private answer that missed the message tool. It would normally trigger recovery."; + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + const explicitPayload = setReplyPayloadMetadata( + { mediaUrl: "file:///tmp/generated.png" }, + { deliverDespiteSourceReplySuppression: true }, + ); + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(substantiveFinal), + accounting: createAccounting([explicitPayload]), + }); + + expect(decision).toMatchObject({ + kind: "deliver", + payloads: [{ mediaUrl: explicitPayload.mediaUrl }], + }); + }); + + it("normalizes explicitly allowed payloads before skipping stranded recovery", () => { + const substantiveFinal = + "This is a substantive private answer that missed the message tool. It must still trigger recovery when the marked payload is not deliverable."; + const rawPayloads: ReplyPayload[] = [ + { text: " " }, + { text: "HEARTBEAT_OK" }, + { text: "hidden reasoning", isReasoning: true }, + ]; + + for (const rawPayload of rawPayloads) { + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + const explicitPayload = setReplyPayloadMetadata(rawPayload, { + deliverDespiteSourceReplySuppression: true, + }); + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(substantiveFinal), + accounting: createAccounting([explicitPayload]), + }), + ).toMatchObject({ kind: "retry-source-delivery" }); + } + }); + + it("routes settled delivery with the actual runtime provider", () => { + const decision = resolveFollowupDeliveryDecision({ + turn: createTurn(), + execution: createSettledExecution(), + accounting: createAccounting([{ text: "done" }], { + providerUsed: "claude-cli", + modelUsed: "claude-sonnet-4-6", + }), + }); + + expect(decision).toMatchObject({ + kind: "deliver", + resolved: { provider: "claude-cli", model: "claude-sonnet-4-6" }, + }); + }); + + it("normalizes auto-compaction notices with the originating delivery context", () => { + const turn = createTurn(); + turn.queued.originatingChatType = "group"; + turn.queued.originatingReplyToMode = "all"; + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(), + accounting: createAccounting([{ text: "done" }], { + compactionNotice: { text: "compacted" }, + }), + }); + + expect(decision.kind).toBe("deliver"); + if (decision.kind === "deliver") { + expect(getReplyPayloadMetadata(decision.payloads[0] ?? {})?.replyDelivery).toEqual({ + chatType: "group", + replyToMode: "all", + }); + } + }); + + it("turns a second missing source delivery into a sanitized diagnostic", () => { + const turn = createTurn(); + turn.queued.strandedReplyRetry = true; + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(), + accounting: createAccounting(), + }), + ).toMatchObject({ + kind: "deliver-diagnostic", + payload: { isError: true, isStatusNotice: true }, + }); + }); + + it("keeps terminal failure fallback silent for internal follow-ups", () => { + const turn = createTurn(); + turn.queued.run.inputProvenance = { kind: "internal_system", sourceTool: "test" }; + + expect( + resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(), + accounting: createAccounting([], { + terminalFailurePayload: { text: "internal failure", isError: true }, + }), + }), + ).toEqual({ kind: "suppress", reason: "silent" }); + }); + + it("delivers a sanitized terminal failure in message-tool-only mode", () => { + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(), + accounting: createAccounting([], { + terminalFailurePayload: { text: "terminal failure", isError: true }, + }), + }); + + expect(decision).toMatchObject({ + kind: "deliver", + payloads: [{ text: "terminal failure", isError: true }], + }); + }); + + it("keeps a terminal failure when suppressed partial output is present", () => { + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: createSettledExecution(), + accounting: createAccounting([{ text: "private partial" }], { + terminalFailurePayload: { text: "terminal failure", isError: true }, + }), + }); + + expect(decision).toMatchObject({ + kind: "deliver", + payloads: [{ text: "terminal failure", isError: true }], + }); + }); + + it("prefers terminal failure over stranded-text recovery", () => { + const turn = createTurn(); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + const execution = createSettledExecution( + "This incomplete private text is substantive. It must not replace the sanitized failure.", + ); + + const decision = resolveFollowupDeliveryDecision({ + turn, + execution, + accounting: createAccounting([], { + terminalFailurePayload: { text: "terminal failure", isError: true }, + }), + }); + + expect(decision).toMatchObject({ + kind: "deliver", + payloads: [{ text: "terminal failure", isError: true }], + }); + }); +}); + +describe("deliverFollowupDecision", () => { + const createDefaults = (onBlockReply: (payload: ReplyPayload) => Promise) => ({ + defaultModel: "claude", + typingMode: "never" as const, + typing: { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + isActive: vi.fn(() => false), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), + cleanup: vi.fn(), + }, + opts: { onBlockReply }, + }); + + it("keeps dispatcher-only delivery out of a routable origin", async () => { + const onBlockReply = vi.fn(async (_payload: ReplyPayload) => {}); + deliveryState.followupRoute = { route: "dispatcher" }; + deliveryState.routeReply.mockReset(); + + try { + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [{ text: "dispatcher only" }] }, + turn: createTurn(), + defaults: createDefaults(onBlockReply), + runId: "run-1", + runFollowup: vi.fn(async () => {}), + }); + + expect(onBlockReply).toHaveBeenCalledOnce(); + expect(deliveryState.routeReply).not.toHaveBeenCalled(); + } finally { + deliveryState.followupRoute = undefined; + } + }); + + it("never forwards cross-channel reply content to the live dispatcher on route failure", async () => { + const onBlockReply = vi.fn(async (_payload: ReplyPayload) => {}); + deliveryState.routeReply.mockReset(); + deliveryState.routeReply.mockResolvedValue({ ok: false, error: "offline" }); + const turn = createTurn(); + turn.queued.run.messageProvider = "slack"; + + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [{ text: "private reply" }] }, + turn, + defaults: createDefaults(onBlockReply), + runId: "run-1", + runFollowup: vi.fn(async () => {}), + }); + + expect(onBlockReply).toHaveBeenCalledOnce(); + const notice = onBlockReply.mock.calls[0]?.[0]; + expect(notice?.text).not.toContain("private reply"); + expect(notice?.text).toContain("could not deliver"); + }); + + it("allows the latest same-channel dispatcher to recover a route failure", async () => { + const onBlockReply = vi.fn(async (_payload: ReplyPayload) => {}); + deliveryState.routeReply.mockReset(); + deliveryState.routeReply.mockResolvedValue({ ok: false, error: "offline" }); + const turn = createTurn(); + turn.queued.run.messageProvider = "discord"; + + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [{ text: "same-channel reply" }] }, + turn, + defaults: createDefaults(onBlockReply), + runId: "run-1", + runFollowup: vi.fn(async () => {}), + }); + + expect(onBlockReply).toHaveBeenCalledWith( + expect.objectContaining({ text: "same-channel reply" }), + ); + }); + + it("keeps block-status delivery out of the assistant transcript", async () => { + deliveryState.routeReply.mockReset(); + deliveryState.routeReply.mockResolvedValue({ ok: true }); + + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [{ text: "compacting" }] }, + turn: createTurn(), + defaults: createDefaults(vi.fn(async (_payload: ReplyPayload) => {})), + runId: "run-1", + runFollowup: vi.fn(async () => {}), + kind: "block", + }); + + expect(deliveryState.routeReply).toHaveBeenCalledWith( + expect.objectContaining({ mirror: false, replyKind: "block" }), + ); + }); + + it("reports an origin delivery failure when no dispatcher can recover it", async () => { + deliveryState.routeReply.mockReset(); + deliveryState.runtimeError.mockReset(); + deliveryState.routeReply.mockResolvedValue({ ok: false, error: "offline" }); + + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [{ text: "undelivered" }] }, + turn: createTurn(), + defaults: { + defaultModel: "claude", + typingMode: "never", + typing: createDefaults(vi.fn(async (_payload: ReplyPayload) => {})).typing, + }, + runId: "run-1", + runFollowup: vi.fn(async () => {}), + }); + + expect(deliveryState.runtimeError).toHaveBeenCalledWith( + expect.stringContaining("route-reply failed: offline"), + ); + }); +}); diff --git a/src/auto-reply/reply/followup-delivery.ts b/src/auto-reply/reply/followup-delivery.ts index 4d7d7f8a2587..5e15d1af4411 100644 --- a/src/auto-reply/reply/followup-delivery.ts +++ b/src/auto-reply/reply/followup-delivery.ts @@ -1,143 +1,497 @@ /** Prepares queued follow-up payloads for source-channel delivery. */ import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; -import type { MessagingToolSend } from "../../agents/embedded-agent-messaging.types.js"; -import type { ReplyToMode } from "../../config/types.base.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { stripHeartbeatToken } from "../heartbeat.js"; import { - copyReplyPayloadMetadata, + hasCommittedSourceReplyDeliveryEvidence, + hasCompletedSourceReplyDeliveryEvidence, + hasCompletedTerminalDeliveryEvidence, + hasVisibleOutboundDeliveryEvidence, +} from "../../agents/embedded-agent-runner/delivery-evidence.js"; +import { hasDeliberateSilentTerminalReply } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; +import { buildAgentRuntimeDeliveryPlan } from "../../agents/runtime-plan/build.js"; +import { logVerbose } from "../../globals.js"; +import { defaultRuntime } from "../../runtime.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; +import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { getReplyPayloadMetadata, - setReplyPayloadMetadata, + isReplyPayloadStatusNotice, + markReplyPayloadForSourceSuppressionDelivery, } from "../reply-payload.js"; -import type { OriginatingChannelType } from "../templating.js"; import type { ReplyPayload } from "../types.js"; +import { normalizeAssistantFinalDeliveryText } from "./agent-runner-core.js"; +import type { AgentTurnExecutionResult } from "./agent-runner-execution.types.js"; +import { buildEmptyInteractiveReplyPayload } from "./agent-runner-failure-reply.js"; +import type { AccountedAgentTurn } from "./agent-runner-result-accounting.js"; +import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js"; +import { resolveFollowupDeliveryPayloads } from "./followup-delivery-payloads.js"; +import type { AdmittedFollowupTurn, FollowupRunnerParams } from "./followup-turn-admission.js"; +import type { InternalGetReplyOptions } from "./get-reply.types.js"; +import { resolveOriginMessageProvider } from "./origin-routing.js"; +import { warnPrivateMessageToolFinal } from "./private-message-tool-final.js"; +import { enqueueFollowupRun, resolveQueueSettings, type FollowupRun } from "./queue.js"; +import type { ReplyDispatchKind } from "./reply-dispatcher.types.js"; +import { isRoutableChannel, routeReply } from "./route-reply.js"; +import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; import { - resolveOriginAccountId, - resolveOriginMessageProvider, - resolveOriginMessageTo, -} from "./origin-routing.js"; -import { - applyReplyThreading, - filterMessagingToolDuplicates, - filterMessagingToolMediaDuplicates, - resolveMessagingToolPayloadDedupe, -} from "./reply-payloads.js"; -import { createReplyDeliveryContext, resolveReplyToMode } from "./reply-threading.js"; + buildStrandedReplyDeliveryFailurePayload, + resolveStrandedReplyRecovery, +} from "./stranded-reply-recovery.js"; +import { createTypingSignaler } from "./typing-mode.js"; -/** Strips empty/heartbeat payloads, applies threading, and dedupes message-tool sends. */ -export function resolveFollowupDeliveryPayloads(params: { - cfg: OpenClawConfig; - payloads: ReplyPayload[]; - messageProvider?: string; - originatingAccountId?: string; - originatingChannel?: string; - originatingChatType?: string | null; - originatingReplyToMode?: ReplyToMode; - originatingTo?: string; - originatingThreadId?: string | number; - reasoningPayloadsEnabled?: boolean; - commentaryPayloadsEnabled?: boolean; - sentMediaUrls?: string[]; - sentTargets?: MessagingToolSend[]; - sentTexts?: string[]; -}): ReplyPayload[] { - const replyMessageProvider = resolveOriginMessageProvider({ - originatingChannel: params.originatingChannel, - provider: params.messageProvider, - }); - const replyToChannel = replyMessageProvider as OriginatingChannelType | undefined; - const replyToMode = - params.originatingReplyToMode ?? - resolveReplyToMode( - params.cfg, - replyToChannel, - params.originatingAccountId, - params.originatingChatType, - ); - const accountId = resolveOriginAccountId({ - originatingAccountId: params.originatingAccountId, - }); - const replyDelivery = createReplyDeliveryContext(replyToMode, params.originatingChatType); - const replyDeliverySource = replyMessageProvider - ? { - channel: replyMessageProvider, - ...(accountId ? { accountId } : {}), - } - : undefined; - const deliverablePayloads = params.payloads.filter( - (payload) => - !(payload.isReasoning === true && params.reasoningPayloadsEnabled !== true) && - !(payload.isCommentary === true && params.commentaryPayloadsEnabled !== true), - ); - const sanitizedPayloads: ReplyPayload[] = []; - for (const payload of deliverablePayloads) { - const text = payload.text; - const sanitized = - text?.includes("HEARTBEAT_OK") === true - ? copyReplyPayloadMetadata(payload, { - ...payload, - text: stripHeartbeatToken(text, { mode: "message" }).text, - }) - : payload; - // Normalize before callers decide whether the run was empty. Otherwise a - // whitespace-only model payload can suppress the interactive fallback. - if (hasOutboundReplyContent(sanitized, { trimText: true })) { - sanitizedPayloads.push(sanitized); +type FollowupDeliveryDecision = + | { + kind: "deliver"; + payloads: ReplyPayload[]; + resolved?: { provider: string; model: string }; } + | { + kind: "suppress"; + reason: "send-policy" | "room-event" | "silent" | "message-tool-only" | "aborted"; + } + | { + kind: "retry-source-delivery"; + run: FollowupRun; + finalTextLength: number; + resolved: { provider: string; model: string }; + } + | { + kind: "deliver-diagnostic"; + payload: ReplyPayload; + resolved: { provider: string; model: string }; + }; + +/** Resolves one final queued delivery action without performing transport I/O. */ +export function resolveFollowupDeliveryDecision(params: { + turn: AdmittedFollowupTurn; + execution: AgentTurnExecutionResult; + accounting?: AccountedAgentTurn & { compactionNotice?: ReplyPayload }; + opts?: InternalGetReplyOptions; +}): FollowupDeliveryDecision { + const { turn, execution, accounting, opts } = params; + if (turn.sendPolicy === "deny") { + return { kind: "suppress", reason: "send-policy" }; } - const replyTaggedPayloads = applyReplyThreading({ - payloads: sanitizedPayloads, - replyToMode, - replyToChannel, - }).map((payload) => - setReplyPayloadMetadata(payload, { - replyDelivery, - ...(replyDeliverySource ? { replyDeliverySource } : {}), - }), - ); - const sentMediaUrlFallback = params.sentMediaUrls ?? []; - const sentTextFallback = params.sentTexts ?? []; - const originatingTo = resolveOriginMessageTo({ - originatingTo: params.originatingTo, + if (turn.queued.currentInboundEventKind === "room_event") { + return { kind: "suppress", reason: "room-event" }; + } + if ( + execution.outcome.kind === "aborted" || + (execution.outcome.kind === "settled" && execution.outcome.abortReason) + ) { + return { kind: "suppress", reason: "aborted" }; + } + const sourcePolicy = resolveSourceReplyVisibilityPolicy({ + cfg: turn.config, + ctx: { + ChatType: turn.queued.originatingChatType ?? turn.queued.run.chatType, + InboundEventKind: turn.queued.currentInboundEventKind, + Provider: turn.queued.originatingChannel ?? turn.queued.run.messageProvider, + Surface: turn.queued.originatingChannel ?? turn.queued.run.messageProvider, + }, + requested: turn.queued.run.sourceReplyDeliveryMode ?? opts?.sourceReplyDeliveryMode, + sendPolicy: turn.sendPolicy, }); - const dedupedPayloads: ReplyPayload[] = []; - for (const payload of replyTaggedPayloads) { - const decision = resolveMessagingToolPayloadDedupe({ - config: params.cfg, - messageProvider: replyMessageProvider, - messagingToolSentTargets: params.sentTargets, - originatingTo, - originatingThreadId: params.originatingThreadId, - replyToId: payload.replyToId, - replyToIsExplicit: Boolean( - getReplyPayloadMetadata(payload)?.replyToIdExplicit || - payload.replyToTag || - payload.replyToCurrent, - ), - replyDelivery: getReplyPayloadMetadata(payload)?.replyDelivery, - accountId, + const hasDestination = Boolean( + (isRoutableChannel(turn.queued.originatingChannel) && turn.queued.originatingTo) || + opts?.onBlockReply, + ); + const isInteractive = + hasDestination && + (turn.queued.run.inputProvenance?.kind === "external_user" || + (turn.queued.run.inputProvenance?.kind === undefined && + !isInternalMessageChannel( + turn.queued.originatingChannel ?? turn.queued.run.messageProvider, + ))); + if (execution.outcome.kind === "rejected") { + if (!isInteractive) { + return { kind: "suppress", reason: "silent" }; + } + if ( + sourcePolicy.sourceReplyDeliveryMode === "message_tool_only" && + getReplyPayloadMetadata(execution.outcome.payload)?.deliverDespiteSourceReplySuppression !== + true + ) { + return { kind: "suppress", reason: "message-tool-only" }; + } + const payloads = resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: [execution.outcome.payload], + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + reasoningPayloadsEnabled: opts?.reasoningPayloadsEnabled === true, + commentaryPayloadsEnabled: opts?.commentaryPayloadsEnabled === true, }); - if (!decision.shouldDedupePayloads) { - dedupedPayloads.push(payload); + return payloads.length > 0 + ? { + kind: "deliver", + payloads, + resolved: execution.outcome.resolved, + } + : { kind: "suppress", reason: "silent" }; + } + if (!accounting) { + return { kind: "suppress", reason: "silent" }; + } + const runtimeResolved = { + provider: accounting.providerUsed, + model: accounting.modelUsed, + }; + const result = execution.outcome.result; + const completedSourceDelivery = hasCompletedSourceReplyDeliveryEvidence(result); + const assistantFinalText = normalizeAssistantFinalDeliveryText( + typeof result.meta?.finalAssistantVisibleText === "string" + ? result.meta.finalAssistantVisibleText + : "", + ); + let payloads = resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: accounting.payloadArray, + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + reasoningPayloadsEnabled: opts?.reasoningPayloadsEnabled === true, + commentaryPayloadsEnabled: opts?.commentaryPayloadsEnabled === true, + sentMediaUrls: result.messagingToolSentMediaUrls, + sentTargets: result.messagingToolSentTargets, + sentTexts: result.messagingToolSentTexts, + }); + const hasExplicitlyDeliverablePayload = payloads.some( + (payload) => getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true, + ); + const recovery = + hasExplicitlyDeliverablePayload || accounting.terminalFailurePayload + ? ({ kind: "none" } as const) + : resolveStrandedReplyRecovery({ + base: turn.queued, + finalText: assistantFinalText, + sourceReplyDeliveryMode: sourcePolicy.sourceReplyDeliveryMode, + sendPolicyDenied: sourcePolicy.sendPolicyDenied, + successfulSourceReplyDelivery: completedSourceDelivery, + isHeartbeat: opts?.isHeartbeat === true, + isRoomEvent: false, + }); + if (recovery.kind === "retry") { + return { + kind: "retry-source-delivery", + run: recovery.run, + finalTextLength: assistantFinalText.trim().length, + resolved: runtimeResolved, + }; + } + if (recovery.kind === "diagnostic") { + const [payload] = resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: [recovery.payload], + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + }); + if (!payload) { + return { kind: "suppress", reason: "silent" }; + } + return { + kind: "deliver-diagnostic", + payload, + resolved: runtimeResolved, + }; + } + const hasCommittedDelivery = + hasVisibleOutboundDeliveryEvidence(result) || + hasCommittedSourceReplyDeliveryEvidence(result) || + result.didSendDeterministicApprovalPrompt === true; + const fallbackPayload = accounting.terminalFailurePayload + ? isInteractive && !hasCompletedTerminalDeliveryEvidence(result) + ? sourcePolicy.sourceReplyDeliveryMode === "message_tool_only" + ? markReplyPayloadForSourceSuppressionDelivery(accounting.terminalFailurePayload) + : accounting.terminalFailurePayload + : undefined + : buildEmptyInteractiveReplyPayload({ + isInteractive, + isHeartbeat: opts?.isHeartbeat, + silentExpected: turn.queued.run.silentExpected, + allowEmptyAssistantReplyAsSilent: turn.queued.run.allowEmptyAssistantReplyAsSilent, + isMessageToolOnly: sourcePolicy.sourceReplyDeliveryMode === "message_tool_only", + hasPendingContinuation: + result.meta?.yielded === true || (result.meta?.pendingToolCalls?.length ?? 0) > 0, + hasExplicitSilentReply: hasDeliberateSilentTerminalReply(result), + hasCommittedDelivery, + sessionCtx: { + ChatType: turn.queued.originatingChatType, + Provider: turn.queued.run.messageProvider, + SessionKey: turn.session.kind === "session" ? turn.session.key : undefined, + Surface: turn.queued.originatingChannel, + }, + cfg: turn.config, + }); + const hasTerminalPayload = payloads.some( + (payload) => + payload.isReasoning !== true && + payload.isCommentary !== true && + !isReplyPayloadStatusNotice(payload) && + (sourcePolicy.sourceReplyDeliveryMode !== "message_tool_only" || + getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true), + ); + if (!hasTerminalPayload && fallbackPayload) { + payloads = [ + ...payloads, + ...resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: [fallbackPayload], + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + }), + ]; + } + if (accounting.compactionNotice) { + const compactionNotices = resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: [accounting.compactionNotice], + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + }); + payloads = [...compactionNotices, ...payloads]; + } + const responseUsageLine = resolveResponseUsageLine({ + config: turn.config, + sessionRaw: turn.session.current()?.responseUsage, + channel: resolveOriginMessageProvider({ + originatingChannel: turn.queued.originatingChannel, + provider: turn.queued.run.messageProvider, + }), + usage: accounting.usage, + provider: accounting.providerUsed, + model: accounting.modelUsed, + preserveUserFacingSessionState: accounting.preserveUserFacingSessionState, + replyUsageState: accounting.replyUsageState, + }); + if (responseUsageLine) { + payloads = appendUsageLine(payloads, responseUsageLine); + } + if (sourcePolicy.sourceReplyDeliveryMode === "message_tool_only") { + const explicitlyDeliverable = payloads.filter( + (payload) => getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true, + ); + return explicitlyDeliverable.length > 0 + ? { kind: "deliver", payloads: explicitlyDeliverable, resolved: runtimeResolved } + : { kind: "suppress", reason: "message-tool-only" }; + } + return payloads.length > 0 + ? { kind: "deliver", payloads, resolved: runtimeResolved } + : { kind: "suppress", reason: "silent" }; +} + +async function sendFollowupPayloads(params: { + payloads: ReplyPayload[]; + turn: AdmittedFollowupTurn; + defaults: FollowupRunnerParams; + runId: string; + kind: ReplyDispatchKind; + mirror?: boolean; + resolved?: { provider: string; model: string }; +}): Promise { + const { turn, defaults } = params; + const { originatingChannel, originatingTo } = turn.queued; + const originRoutable = Boolean(isRoutableChannel(originatingChannel) && originatingTo); + const deliveryPlan = buildAgentRuntimeDeliveryPlan({ + provider: params.resolved?.provider ?? turn.queued.run.provider, + modelId: params.resolved?.model ?? turn.queued.run.model, + config: turn.config, + workspaceDir: turn.queued.run.workspaceDir, + agentDir: turn.queued.run.agentDir, + }); + const payloads = params.payloads.filter( + (payload) => + hasOutboundReplyContent(payload) && + (!deliveryPlan.isSilentPayload(payload) || + getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true), + ); + if (payloads.length === 0) { + return; + } + if (!originRoutable && !defaults.opts?.onBlockReply) { + defaultRuntime.error?.( + "followup queue: completed with payloads but no origin route or visible dispatcher is available", + ); + return; + } + const typing = createTypingSignaler({ + typing: defaults.typing, + mode: defaults.typingMode, + isHeartbeat: defaults.opts?.isHeartbeat === true, + }); + let crossChannelFailure = false; + let deliveredCrossChannelOrigin = false; + for (const payload of payloads) { + const providerRoute = deliveryPlan.resolveFollowupRoute({ + payload, + originatingChannel, + originatingTo, + originRoutable, + dispatcherAvailable: Boolean(defaults.opts?.onBlockReply), + }); + if (providerRoute?.route === "drop") { continue; } - const sentMediaUrls = - decision.matchingRoute && !decision.useGlobalSentMediaUrlEvidenceFallback - ? decision.routeSentMediaUrls - : sentMediaUrlFallback; - const sentTexts = - decision.matchingRoute && !decision.useGlobalSentTextEvidenceFallback - ? decision.routeSentTexts - : sentTextFallback; - const mediaFiltered = filterMessagingToolMediaDuplicates({ - payloads: [payload], - sentMediaUrls, - }); - const textFiltered = filterMessagingToolDuplicates({ - payloads: mediaFiltered, - sentTexts, - }); - dedupedPayloads.push(...textFiltered); + const route = + providerRoute?.route === "origin" && originRoutable + ? "origin" + : providerRoute?.route === "dispatcher" && defaults.opts?.onBlockReply + ? "dispatcher" + : originRoutable + ? "origin" + : "dispatcher"; + await typing.signalTextDelta(payload.text); + if (route !== "origin") { + await defaults.opts?.onBlockReply?.(payload); + } else if (isRoutableChannel(originatingChannel) && originatingTo) { + const metadata = getReplyPayloadMetadata(payload); + const result = await routeReply({ + payload, + channel: originatingChannel, + to: originatingTo, + sessionKey: turn.queued.run.sessionKey, + accountId: turn.queued.originatingAccountId, + requesterSenderId: turn.queued.run.senderId, + requesterSenderName: turn.queued.run.senderName, + requesterSenderUsername: turn.queued.run.senderUsername, + requesterSenderE164: turn.queued.run.senderE164, + threadId: turn.queued.originatingThreadId, + cfg: turn.config, + mirror: + metadata?.assistantMessageIndex !== undefined || + metadata?.assistantTranscriptOwned === true + ? false + : params.mirror, + replyKind: params.kind, + runId: params.runId, + }); + if (!result.ok) { + logVerbose(`followup queue: route-reply failed: ${result.error ?? "unknown error"}`); + const provider = resolveOriginMessageProvider({ + provider: turn.queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ originatingChannel }); + if (origin && origin === provider && defaults.opts?.onBlockReply) { + await defaults.opts.onBlockReply(payload); + } else if (defaults.opts?.onBlockReply) { + crossChannelFailure = true; + } else { + defaultRuntime.error?.( + `followup queue: route-reply failed: ${result.error ?? "unknown error"}`, + ); + } + } else if (!result.suppressed) { + const provider = resolveOriginMessageProvider({ + provider: turn.queued.run.messageProvider, + }); + const origin = resolveOriginMessageProvider({ originatingChannel }); + deliveredCrossChannelOrigin ||= Boolean(origin && provider && origin !== provider); + } + } + } + if (crossChannelFailure && !deliveredCrossChannelOrigin && defaults.opts?.onBlockReply) { + await defaults.opts.onBlockReply({ + text: + "Follow-up completed, but OpenClaw could not deliver it to the originating channel. " + + "The reply content was not forwarded to this channel to avoid cross-channel misdelivery.", + isError: true, + }); } - return dedupedPayloads; +} + +/** Performs the already-resolved follow-up delivery action. */ +export async function deliverFollowupDecision(params: { + decision: FollowupDeliveryDecision; + turn: AdmittedFollowupTurn; + defaults: FollowupRunnerParams; + runId: string; + runFollowup: (run: FollowupRun) => Promise; + kind?: ReplyDispatchKind; +}): Promise { + const { decision, turn, defaults } = params; + if (decision.kind === "suppress") { + logVerbose(`followup queue: delivery suppressed (${decision.reason})`); + return; + } + if (decision.kind === "retry-source-delivery") { + warnPrivateMessageToolFinal({ + sessionKey: turn.session.kind === "session" ? turn.session.key : undefined, + channel: + turn.queued.originatingChannel ?? + turn.queued.run.messageProvider ?? + sessionDeliveryChannel(turn.session.current()), + finalTextLength: decision.finalTextLength, + }); + const key = turn.session.kind === "session" ? turn.session.key : turn.queued.run.sessionKey; + const enqueued = + key && + enqueueFollowupRun( + key, + decision.run, + resolveQueueSettings({ + cfg: turn.config, + channel: turn.queued.originatingChannel ?? turn.queued.run.messageProvider, + sessionEntry: turn.session.current(), + }), + "none", + params.runFollowup, + false, + { position: "front" }, + ); + if (enqueued) { + return; + } + const diagnosticPayloads = resolveFollowupDeliveryPayloads({ + cfg: turn.config, + payloads: [buildStrandedReplyDeliveryFailurePayload()], + messageProvider: turn.queued.run.messageProvider, + originatingAccountId: turn.queued.originatingAccountId ?? turn.queued.run.agentAccountId, + originatingChannel: turn.queued.originatingChannel, + originatingChatType: turn.queued.originatingChatType, + originatingReplyToMode: turn.queued.originatingReplyToMode, + originatingTo: turn.queued.originatingTo, + originatingThreadId: turn.queued.originatingThreadId, + }); + await sendFollowupPayloads({ + payloads: diagnosticPayloads, + turn, + defaults, + runId: params.runId, + kind: params.kind ?? "final", + resolved: decision.resolved, + }); + return; + } + await sendFollowupPayloads({ + payloads: decision.kind === "deliver" ? decision.payloads : [decision.payload], + turn, + defaults, + runId: params.runId, + kind: params.kind ?? "final", + mirror: params.kind && params.kind !== "final" ? false : undefined, + resolved: decision.resolved, + }); } diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts index b822edbe2d3c..9980f930a8c6 100644 --- a/src/auto-reply/reply/followup-runner.test.ts +++ b/src/auto-reply/reply/followup-runner.test.ts @@ -1,6925 +1,324 @@ -// Tests follow-up runner delivery, transcript persistence, and no-reply contracts. -import fsSync from "node:fs"; -import fs from "node:fs/promises"; -import { tmpdir } from "node:os"; -import path from "node:path"; -import { expectDefined } from "@openclaw/normalization-core"; -import { DELIVERY_NO_REPLY_RUNTIME_CONTRACT } from "openclaw/plugin-sdk/agent-runtime-test-contracts"; -import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; -import { setCliSessionBinding } from "../../agents/cli-session.js"; -import type { OpenClawConfig } from "../../config/config.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; -import { resetAgentEventsForTest } from "../../infra/agent-events.js"; -import { - createUserTurnTranscriptRecorder, - type PersistedUserTurnMessage, -} from "../../sessions/user-turn-transcript.js"; -import { createTestUserTurnTranscriptTarget } from "../../sessions/user-turn-transcript.test-support.js"; -import type { GetReplyOptions } from "../types.js"; -import { GENERIC_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js"; -import type { FollowupRun, QueueSettings } from "./queue.js"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ReplyPayload } from "../types.js"; +import type { AdmittedFollowupTurn } from "./followup-turn-admission.js"; +import type { FollowupExecutionResult } from "./followup-turn-execution.js"; +import type { FollowupRun } from "./queue.js"; -const runEmbeddedAgentMock = vi.fn(); -const runCliAgentMock = vi.fn(); -const runWithModelFallbackMock = vi.fn(); -const compactEmbeddedAgentSessionMock = vi.fn(); -const routeReplyMock = vi.fn(); -const isRoutableChannelMock = vi.fn(); -const runReplyPayloadSendingHookMock = vi.fn(); -const runPreflightCompactionIfNeededMock = vi.fn(); -const resolveCommandSecretRefsViaGatewayMock = vi.fn(); -const resolveQueuedReplyExecutionConfigMock = vi.fn(); -const resolveProviderFollowupFallbackRouteMock = vi.fn(); -const resolveProviderThinkingProfileMock = vi.fn(); -const admitReplyTurnMock = vi.fn(); -let resolveQueuedReplyExecutionConfigActual: - | (typeof import("./agent-runner-utils.js"))["resolveQueuedReplyExecutionConfig"] - | undefined; -let createFollowupRunner: typeof import("./followup-runner.js").createFollowupRunner; -let clearRuntimeConfigSnapshot: typeof import("../../config/config.js").clearRuntimeConfigSnapshot; -let loadSessionEntry: typeof import("../../config/sessions/session-accessor.js").loadSessionEntry; -let replaceSessionEntrySync: typeof import("../../config/sessions/session-accessor.js").replaceSessionEntrySync; -let clearSessionStoreCacheForTest: typeof import("../../config/sessions/store-writer-state.js").clearSessionStoreCacheForTest; -let clearFollowupQueue: typeof import("./queue/state.js").clearFollowupQueue; -let enqueueFollowupRun: typeof import("./queue.js").enqueueFollowupRun; -let sessionRunAccounting: typeof import("./session-run-accounting.js"); -let setRuntimeConfigSnapshot: typeof import("../../config/config.js").setRuntimeConfigSnapshot; -let createMockFollowupRun: typeof import("./test-helpers.js").createMockFollowupRun; -let createMockTypingController: typeof import("./test-helpers.js").createMockTypingController; -let createReplyOperationForTest: typeof import("./reply-run-registry.js").createReplyOperation; -let abortActiveReplyRunsForTest: typeof import("./reply-run-registry.js").abortActiveReplyRuns; -let replyRunRegistryForTest: typeof import("./reply-run-registry.js").replyRunRegistry; -let replyRunTestingForTest: typeof import("./reply-run-registry.test-support.js").testing; -let cliBackendsTestingForTest: typeof import("../../agents/cli-backends.test-support.js").testing; -let setReplyPayloadMetadataForTest: typeof import("../reply-payload.js").setReplyPayloadMetadata; -let getReplyPayloadMetadataForTest: typeof import("../reply-payload.js").getReplyPayloadMetadata; -const FOLLOWUP_DEBUG = process.env.OPENCLAW_DEBUG_FOLLOWUP_RUNNER_TEST === "1"; -const FOLLOWUP_TEST_QUEUES = new Map< - string, - { - items: FollowupRun[]; - lastRun?: FollowupRun["run"]; - } ->(); -const FOLLOWUP_TEST_SESSION_STORES = new Map>(); -const FOLLOWUP_TEST_SESSION_STORE_PATHS = new Set(); +const state = vi.hoisted(() => ({ + account: vi.fn(), + admit: vi.fn(), + completeLifecycle: vi.fn(), + completedSourceDelivery: false, + deliver: vi.fn(), + execute: vi.fn(), + resolveDecision: vi.fn(), + clearRunContext: vi.fn(), +})); -function debugFollowupTest(message: string): void { - if (!FOLLOWUP_DEBUG) { - return; - } - process.stderr.write(`[followup-runner.test] ${message}\n`); -} +vi.mock("../../infra/agent-events.js", () => ({ + clearAgentRunContext: (...args: unknown[]) => state.clearRunContext(...args), +})); -function joinPromptSections(...sections: Array): string { - const promptSections: string[] = []; - for (const section of sections) { - if (section) { - promptSections.push(section); - } - } - return promptSections.join("\n\n"); -} +vi.mock("../../agents/embedded-agent-runner/delivery-evidence.js", () => ({ + hasCompletedSourceReplyDeliveryEvidence: () => state.completedSourceDelivery, +})); -function createTestUserTurnRecorder(message: PersistedUserTurnMessage) { - return createUserTurnTranscriptRecorder({ - message, - target: createTestUserTurnTranscriptTarget(), - updateMode: "none", - }); -} +vi.mock("./agent-runner-result-accounting.js", () => ({ + accountFollowupTurn: (...args: unknown[]) => state.account(...args), +})); -function requireRecord(value: unknown, label: string): Record { - if (!value || typeof value !== "object" || Array.isArray(value)) { - throw new Error(`expected ${label} to be an object`); - } - return value as Record; -} +vi.mock("./followup-turn-admission.js", () => ({ + admitFollowupTurn: (...args: unknown[]) => state.admit(...args), +})); -function requireMockCallArg( - mock: { mock: { calls: unknown[][] } }, - index: number, -): Record { - const call = mock.mock.calls[index]; - if (!call) { - throw new Error(`expected mock call ${index}`); - } - return requireRecord(call[0], `mock call ${index} arg`); -} +vi.mock("./followup-turn-execution.js", () => ({ + executeFollowupTurn: (...args: unknown[]) => state.execute(...args), +})); -function requireLastMockCallArg( - mock: { mock: { calls: unknown[][] } }, - label: string, -): Record { - const calls = mock.mock.calls; - const call = calls[calls.length - 1]; - if (!call) { - throw new Error(`expected ${label} mock call`); - } - return requireRecord(call[0], `${label} mock call arg`); -} +vi.mock("./followup-delivery.js", () => ({ + deliverFollowupDecision: (...args: unknown[]) => state.deliver(...args), + resolveFollowupDeliveryDecision: (...args: unknown[]) => state.resolveDecision(...args), +})); -function expectBlockReplyText(onBlockReply: { mock: { calls: unknown[][] } }, text: string): void { - expect( - onBlockReply.mock.calls.some( - (call) => requireRecord(call[0], "block reply payload").text === text, - ), - ).toBe(true); -} +vi.mock("./queue.js", () => ({ + completeFollowupRunLifecycle: (...args: unknown[]) => state.completeLifecycle(...args), + FollowupRunDeferredError: class FollowupRunDeferredError extends Error {}, +})); -function expectNoBlockReplyText( - onBlockReply: { mock: { calls: unknown[][] } }, - text: string, -): void { - expect( - onBlockReply.mock.calls.some( - (call) => requireRecord(call[0], "block reply payload").text === text, - ), - ).toBe(false); -} +vi.mock("../../runtime.js", () => ({ defaultRuntime: { error: vi.fn() } })); -function expectNoBlockReplyTextIncludes( - onBlockReply: { mock: { calls: unknown[][] } }, - fragment: string, -): void { - expect( - onBlockReply.mock.calls.some((call) => - String(requireRecord(call[0], "block reply payload").text).includes(fragment), - ), - ).toBe(false); -} +const { createFollowupRunner } = await import("./followup-runner.js"); +const { FollowupRunDeferredError } = await import("./queue.js"); -function registerFollowupTestSessionStore( - storePath: string, - sessionStore: Record, -): void { - fsSync.mkdirSync(path.dirname(storePath), { recursive: true }); - // Seed the sqlite accessor so the runner's loadSessionEntry/admitReplyTurn reads - // observe these fixtures; the in-memory map still backs the mocked accounting helpers. - for (const [sessionKey, entry] of Object.entries(sessionStore)) { - replaceSessionEntrySync({ sessionKey, storePath }, entry); - } - FOLLOWUP_TEST_SESSION_STORES.set(storePath, sessionStore); - FOLLOWUP_TEST_SESSION_STORE_PATHS.add(storePath); -} - -async function incrementRunCompactionCountForFollowupTest( - params: Parameters[0], -): Promise { - const { - sessionStore, - sessionKey, - sessionEntry, - amount = 1, - newSessionId, - lastCallUsage, - } = params; - if (!sessionStore || !sessionKey) { - return undefined; - } - const entry = sessionStore[sessionKey] ?? sessionEntry; - if (!entry) { - return undefined; - } - - const nextCount = Math.max(0, entry.compactionCount ?? 0) + Math.max(0, amount); - const nextEntry: SessionEntry = { - ...entry, - compactionCount: nextCount, - updatedAt: Date.now(), - }; - if (newSessionId && newSessionId !== entry.sessionId) { - nextEntry.sessionId = newSessionId; - if (entry.sessionFile?.trim()) { - nextEntry.sessionFile = path.join(path.dirname(entry.sessionFile), `${newSessionId}.jsonl`); - } - } - const promptTokens = - (lastCallUsage?.input ?? 0) + - (lastCallUsage?.cacheRead ?? 0) + - (lastCallUsage?.cacheWrite ?? 0); - if (promptTokens > 0) { - nextEntry.totalTokens = promptTokens; - nextEntry.totalTokensFresh = true; - nextEntry.inputTokens = undefined; - nextEntry.outputTokens = undefined; - nextEntry.cacheRead = undefined; - nextEntry.cacheWrite = undefined; - } - - sessionStore[sessionKey] = nextEntry; - if (sessionEntry) { - Object.assign(sessionEntry, nextEntry); - } - return nextCount; -} - -function getFollowupTestQueue(key: string): { - items: FollowupRun[]; - lastRun?: FollowupRun["run"]; -} { - const cleaned = key.trim(); - const existing = FOLLOWUP_TEST_QUEUES.get(cleaned); - if (existing) { - return existing; - } - const created = { - items: [] as FollowupRun[], - lastRun: undefined as FollowupRun["run"] | undefined, - }; - FOLLOWUP_TEST_QUEUES.set(cleaned, created); - return created; -} - -function clearFollowupQueueForFollowupTest(key: string): number { - const cleaned = key.trim(); - const queue = FOLLOWUP_TEST_QUEUES.get(cleaned); - if (!queue) { - return 0; - } - const cleared = queue.items.length; - FOLLOWUP_TEST_QUEUES.delete(cleaned); - return cleared; -} - -function enqueueFollowupRunForFollowupTest( - key: string, - run: FollowupRun, - _settings?: QueueSettings, - _dedupeMode?: unknown, - _runFollowup?: unknown, - _restartIfIdle?: unknown, - options?: { position?: "tail" | "front" }, -): boolean { - if (options?.position === "front") { - run.protectFromQueueOverflow = true; - } - const queue = getFollowupTestQueue(key); - if (options?.position === "front") { - queue.items.unshift(run); - } else { - queue.items.push(run); - } - queue.lastRun = run.run; - return true; -} - -function refreshQueuedFollowupSessionForFollowupTest(params: { - key: string; - previousSessionId?: string; - nextSessionId?: string; - nextSessionFile?: string; - nextProvider?: string; - nextModel?: string; - nextAuthProfileId?: string; - nextAuthProfileIdSource?: "auto" | "user"; -}): void { - const cleaned = params.key.trim(); - if (!cleaned) { - return; - } - const queue = FOLLOWUP_TEST_QUEUES.get(cleaned); - if (!queue) { - return; - } - const shouldRewriteSession = - Boolean(params.previousSessionId) && - Boolean(params.nextSessionId) && - params.previousSessionId !== params.nextSessionId; - const shouldRewriteSelection = - typeof params.nextProvider === "string" || - typeof params.nextModel === "string" || - Object.hasOwn(params, "nextAuthProfileId") || - Object.hasOwn(params, "nextAuthProfileIdSource"); - if (!shouldRewriteSession && !shouldRewriteSelection) { - return; - } - const rewrite = (run?: FollowupRun["run"]) => { - if (!run) { - return; - } - if (shouldRewriteSession && run.sessionId === params.previousSessionId) { - run.sessionId = params.nextSessionId!; - if (params.nextSessionFile?.trim()) { - run.sessionFile = params.nextSessionFile; - } - } - if (shouldRewriteSelection) { - if (typeof params.nextProvider === "string") { - run.provider = params.nextProvider; - } - if (typeof params.nextModel === "string") { - run.model = params.nextModel; - } - if (Object.hasOwn(params, "nextAuthProfileId")) { - run.authProfileId = params.nextAuthProfileId?.trim() || undefined; - } - if (Object.hasOwn(params, "nextAuthProfileIdSource")) { - run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined; - } - } - }; - rewrite(queue.lastRun); - for (const item of queue.items) { - rewrite(item.run); - } -} - -async function persistRunSessionUsageForFollowupTest( - params: Parameters[0], -): Promise { - const { storePath, sessionKey } = params; - if (!storePath || !sessionKey) { - return; - } - const registeredStore = FOLLOWUP_TEST_SESSION_STORES.get(storePath); - const entry = registeredStore?.[sessionKey] ?? loadSessionEntry({ storePath, sessionKey }); - if (!entry) { - return; - } - const preserveSessionModelState = - params.isHeartbeat === true || - params.preserveRuntimeModel === true || - params.preserveUserFacingSessionModelState === true; - const preserveUserFacingRunState = params.preserveUserFacingSessionModelState === true; - const nextEntry: SessionEntry = { - ...entry, - updatedAt: Date.now(), - modelProvider: preserveSessionModelState - ? entry.modelProvider - : (params.providerUsed ?? entry.modelProvider), - model: preserveSessionModelState ? entry.model : (params.modelUsed ?? entry.model), - contextTokens: preserveSessionModelState - ? entry.contextTokens - : (params.contextTokensUsed ?? entry.contextTokens), - systemPromptReport: preserveUserFacingRunState - ? entry.systemPromptReport - : (params.systemPromptReport ?? entry.systemPromptReport), - }; - if (params.usage && !preserveUserFacingRunState) { - nextEntry.inputTokens = params.usage.input ?? 0; - nextEntry.outputTokens = params.usage.output ?? 0; - const cacheUsage = params.lastCallUsage ?? params.usage; - nextEntry.cacheRead = cacheUsage?.cacheRead ?? 0; - nextEntry.cacheWrite = cacheUsage?.cacheWrite ?? 0; - } - if (!preserveUserFacingRunState) { - const promptTokens = - params.promptTokens ?? - (params.lastCallUsage?.input ?? params.usage?.input ?? 0) + - (params.lastCallUsage?.cacheRead ?? params.usage?.cacheRead ?? 0) + - (params.lastCallUsage?.cacheWrite ?? params.usage?.cacheWrite ?? 0); - nextEntry.totalTokens = promptTokens > 0 ? promptTokens : undefined; - nextEntry.totalTokensFresh = promptTokens > 0; - } - if (params.cliSessionBinding && params.providerUsed && !preserveUserFacingRunState) { - setCliSessionBinding(nextEntry, params.providerUsed, params.cliSessionBinding); - } - if (registeredStore) { - registeredStore[sessionKey] = nextEntry; - return; - } - replaceSessionEntrySync({ storePath, sessionKey }, nextEntry); -} - -async function loadFreshFollowupRunnerModuleForTest() { - vi.resetModules(); - vi.doUnmock("../../config/config.js"); - vi.doMock("../../agents/model-fallback.js", () => ({ - isFallbackSummaryError: (err: unknown) => - err instanceof Error && err.name === "FallbackSummaryError", - runWithModelFallback: (params: unknown) => runWithModelFallbackMock(params), - })); - vi.doMock("../../agents/session-write-lock.js", () => ({ - acquireSessionWriteLock: vi.fn(async () => ({ - release: async () => {}, - })), - resolveSessionLockMaxHoldFromTimeout: vi.fn(() => 1), - })); - vi.doMock("../../agents/embedded-agent.js", () => ({ - abortEmbeddedAgentRun: vi.fn(async () => false), - compactEmbeddedAgentSession: (params: unknown) => compactEmbeddedAgentSessionMock(params), - isEmbeddedAgentRunActive: vi.fn(() => false), - isEmbeddedAgentRunStreaming: vi.fn(() => false), - resolveEmbeddedSessionLane: (key: string) => `session:${key.trim() || "main"}`, - runEmbeddedAgent: (params: unknown) => runEmbeddedAgentMock(params), - waitForEmbeddedAgentRunEnd: vi.fn(async () => undefined), - })); - vi.doMock("../../agents/cli-runner.js", () => ({ - runCliAgent: (params: unknown) => runCliAgentMock(params), - })); - vi.doMock("./queue.js", () => ({ - admitFollowupRunLifecycle: async (run: Pick) => { - await run.turnAdoptionLifecycle?.onAdopted?.(); - }, - clearFollowupQueue: clearFollowupQueueForFollowupTest, - completeFollowupRunLifecycle: (run: Pick) => - run.turnAdoptionLifecycle?.onSettled?.(), - enqueueFollowupRun: enqueueFollowupRunForFollowupTest, - isFollowupRunAborted: (run: Pick) => - run.abortSignal?.aborted === true || run.queueAbortSignal?.aborted === true, - resolveFollowupAbortSignal: (run: Pick) => { - const signals = [run.abortSignal, run.queueAbortSignal].filter( - (signal): signal is AbortSignal => signal !== undefined, - ); - return signals.length > 1 ? AbortSignal.any(signals) : signals[0]; - }, - refreshQueuedFollowupSession: refreshQueuedFollowupSessionForFollowupTest, - resolveQueueSettings: (): QueueSettings => ({ mode: "followup" }), - })); - vi.doMock("./reply-turn-admission.js", async () => { - const actual = await vi.importActual( - "./reply-turn-admission.js", - ); - return { - ...actual, - admitReplyTurn: (...args: Parameters) => - admitReplyTurnMock.getMockImplementation() - ? admitReplyTurnMock(...args) - : actual.admitReplyTurn(...args), - }; - }); - vi.doMock("./session-run-accounting.js", () => ({ - persistRunSessionUsage: persistRunSessionUsageForFollowupTest, - incrementRunCompactionCount: incrementRunCompactionCountForFollowupTest, - })); - vi.doMock("./agent-runner-memory.js", () => ({ - runMemoryFlushIfNeeded: async (params: { sessionEntry?: SessionEntry }) => ({ - sessionEntry: params.sessionEntry, - outcome: "skipped", - }), - runPreflightCompactionIfNeeded: (...args: unknown[]) => - runPreflightCompactionIfNeededMock(...args), - })); - vi.doMock("./route-reply.js", () => ({ - isRoutableChannel: (...args: unknown[]) => isRoutableChannelMock(...args), - routeReply: (...args: unknown[]) => routeReplyMock(...args), - })); - vi.doMock("./reply-payload-sending-hook.js", () => ({ - runReplyPayloadSendingHook: (...args: unknown[]) => runReplyPayloadSendingHookMock(...args), - })); - vi.doMock("../../plugins/provider-runtime.js", async () => { - const actual = await vi.importActual( - "../../plugins/provider-runtime.js", - ); - return { - ...actual, - resolveProviderFollowupFallbackRoute: (...args: unknown[]) => - resolveProviderFollowupFallbackRouteMock(...args), - }; - }); - vi.doMock("../../plugins/provider-thinking.js", async () => { - const actual = await vi.importActual( - "../../plugins/provider-thinking.js", - ); - return { - ...actual, - resolveProviderThinkingProfile: (...args: unknown[]) => - resolveProviderThinkingProfileMock(...args), - }; - }); - vi.doMock("./agent-runner-utils.js", async () => { - const actual = - await vi.importActual("./agent-runner-utils.js"); - resolveQueuedReplyExecutionConfigActual = actual.resolveQueuedReplyExecutionConfig; - resolveQueuedReplyExecutionConfigMock.mockImplementation( - async (...args: Parameters) => - await actual.resolveQueuedReplyExecutionConfig(...args), - ); - return { - ...actual, - resolveQueuedReplyExecutionConfig: ( - ...args: Parameters - ) => resolveQueuedReplyExecutionConfigMock(...args), - }; - }); - vi.doMock("../../cli/command-secret-gateway.js", () => ({ - resolveCommandSecretRefsViaGateway: (...args: unknown[]) => - resolveCommandSecretRefsViaGatewayMock(...args), - })); - vi.doMock("../../cli/command-secret-targets.js", () => ({ - getAgentRuntimeCommandSecretTargetIds: () => new Set(["skills.entries."]), - getScopedChannelsCommandSecretTargets: ({ - channel, - accountId, - }: { - channel?: string; - accountId?: string; - }) => { - const normalizedChannel = channel?.trim() ?? ""; - if (!normalizedChannel) { - return { targetIds: new Set() }; - } - const targetIds = new Set([`channels.${normalizedChannel}.token`]); - const normalizedAccountId = accountId?.trim() ?? ""; - if (!normalizedAccountId) { - return { targetIds }; - } - return { - targetIds, - allowedPaths: new Set([ - `channels.${normalizedChannel}.token`, - `channels.${normalizedChannel}.accounts.${normalizedAccountId}.token`, - ]), - }; - }, - })); - ({ testing: cliBackendsTestingForTest } = - await import("../../agents/cli-backends.test-support.js")); - setFastFollowupCliBackendDeps(); - ({ createFollowupRunner } = await import("./followup-runner.js")); - ({ clearRuntimeConfigSnapshot, setRuntimeConfigSnapshot } = - await import("../../config/config.js")); - ({ clearSessionStoreCacheForTest } = await import("../../config/sessions/store-writer-state.js")); - ({ loadSessionEntry, replaceSessionEntrySync } = - await import("../../config/sessions/session-accessor.js")); - ({ clearFollowupQueue } = await import("./queue/state.js")); - ({ enqueueFollowupRun } = await import("./queue.js")); - sessionRunAccounting = await import("./session-run-accounting.js"); - ({ createMockFollowupRun, createMockTypingController } = await import("./test-helpers.js")); - ({ - abortActiveReplyRuns: abortActiveReplyRunsForTest, - createReplyOperation: createReplyOperationForTest, - replyRunRegistry: replyRunRegistryForTest, - } = await import("./reply-run-registry.js")); - ({ testing: replyRunTestingForTest } = await import("./reply-run-registry.test-support.js")); - ({ - getReplyPayloadMetadata: getReplyPayloadMetadataForTest, - setReplyPayloadMetadata: setReplyPayloadMetadataForTest, - } = await import("../reply-payload.js")); -} - -function setFastFollowupCliBackendDeps(): void { - const claudeBackend = { - id: "claude-cli", - pluginId: "anthropic", - modelProvider: "anthropic", - config: { command: "claude" }, - bundleMcp: false, - }; - const codexBackend = { - id: "codex", - pluginId: "test-codex-cli", - config: { command: "codex" }, - bundleMcp: false, - }; - cliBackendsTestingForTest.setDepsForTest({ - resolvePluginSetupCliBackend: ({ backend }) => - backend === "claude-cli" - ? { - pluginId: "anthropic", - backend: claudeBackend, - } - : undefined, - resolvePluginSetupRegistry: () => ({ - providers: [], - cliBackends: [], - configMigrations: [], - autoEnableProbes: [], - diagnostics: [], - }), - resolveRuntimeCliBackends: () => [claudeBackend, codexBackend], - }); -} - -const ROUTABLE_TEST_CHANNELS = new Set([ - "telegram", - "slack", - "discord", - "signal", - "imessage", - "whatsapp", - "feishu", -]); - -beforeAll(async () => { - await loadFreshFollowupRunnerModuleForTest(); -}); - -beforeEach(() => { - resetAgentEventsForTest({ preserveListeners: true }); - setFastFollowupCliBackendDeps(); - replyRunTestingForTest?.resetReplyRunRegistry(); - clearRuntimeConfigSnapshot?.(); - runEmbeddedAgentMock.mockReset(); - runCliAgentMock.mockReset(); - runWithModelFallbackMock.mockReset(); - runWithModelFallbackMock.mockImplementation( - async (params: { - provider: string; - model: string; - run: ( - provider: string, - model: string, - options?: { allowTransientCooldownProbe?: boolean }, - ) => Promise; - }) => ({ - result: await params.run(params.provider, params.model), - provider: params.provider, - model: params.model, - }), - ); - compactEmbeddedAgentSessionMock.mockReset(); - runPreflightCompactionIfNeededMock.mockReset(); - resolveCommandSecretRefsViaGatewayMock.mockReset(); - runReplyPayloadSendingHookMock.mockReset(); - runReplyPayloadSendingHookMock.mockImplementation( - async (params: { payload: unknown }) => params.payload, - ); - resolveQueuedReplyExecutionConfigMock.mockReset(); - resolveProviderFollowupFallbackRouteMock.mockReset(); - resolveProviderFollowupFallbackRouteMock.mockReturnValue(undefined); - resolveProviderThinkingProfileMock.mockReset(); - resolveProviderThinkingProfileMock.mockReturnValue(undefined); - admitReplyTurnMock.mockReset(); - const resolveQueuedReplyExecutionConfig = resolveQueuedReplyExecutionConfigActual; - if (!resolveQueuedReplyExecutionConfig) { - throw new Error("resolveQueuedReplyExecutionConfig mock not initialized"); - } - resolveQueuedReplyExecutionConfigMock.mockImplementation( - async (...args: Parameters) => - await resolveQueuedReplyExecutionConfig(...args), - ); - runPreflightCompactionIfNeededMock.mockImplementation( - async (params: { sessionEntry?: SessionEntry }) => params.sessionEntry, - ); - resolveCommandSecretRefsViaGatewayMock.mockImplementation(async ({ config }) => ({ - resolvedConfig: config, - diagnostics: [], - targetStatesByPath: {}, - hadUnresolvedTargets: false, - })); - routeReplyMock.mockReset(); - routeReplyMock.mockResolvedValue({ ok: true }); - isRoutableChannelMock.mockReset(); - isRoutableChannelMock.mockImplementation((ch: string | undefined) => - Boolean(ch?.trim() && ROUTABLE_TEST_CHANNELS.has(ch.trim().toLowerCase())), - ); - clearFollowupQueue("main"); - FOLLOWUP_TEST_QUEUES.clear(); - FOLLOWUP_TEST_SESSION_STORES.clear(); -}); - -afterEach(() => { - resetAgentEventsForTest({ preserveListeners: true }); - cliBackendsTestingForTest?.resetDepsForTest(); - replyRunTestingForTest?.resetReplyRunRegistry(); - clearRuntimeConfigSnapshot?.(); - clearFollowupQueue("main"); - FOLLOWUP_TEST_QUEUES.clear(); - FOLLOWUP_TEST_SESSION_STORES.clear(); - for (const storePath of FOLLOWUP_TEST_SESSION_STORE_PATHS) { - fsSync.rmSync(storePath, { force: true }); - } - FOLLOWUP_TEST_SESSION_STORE_PATHS.clear(); - vi.clearAllTimers(); - vi.useRealTimers(); - clearSessionStoreCacheForTest(); - if (!FOLLOWUP_DEBUG) { - return; - } - const processWithDebugHandles = process as NodeJS.Process & { - _getActiveHandles?: () => unknown[]; - _getActiveRequests?: () => unknown[]; - }; - const handles = processWithDebugHandles["_getActiveHandles"]?.().map( - (handle) => handle?.constructor?.name ?? typeof handle, - ); - debugFollowupTest(`active handles: ${JSON.stringify(handles ?? [])}`); - const requests = processWithDebugHandles["_getActiveRequests"]?.().map( - (request) => request?.constructor?.name ?? typeof request, - ); - debugFollowupTest(`active requests: ${JSON.stringify(requests ?? [])}`); -}); - -const baseQueuedRun = (messageProvider = "whatsapp"): FollowupRun => - createMockFollowupRun({ run: { messageProvider } }); - -function createQueuedRun( - overrides: Partial> & { run?: Partial } = {}, -): FollowupRun { - return createMockFollowupRun(overrides); -} - -describe("createFollowupRunner reply-lane admission", () => { - // Goal-context text refresh is covered directly in inbound-meta.test.ts; the - // admission-time composition boundary is covered in get-reply-run.media-only.test.ts. - it("keeps the originating client caps on queued embedded runs", async () => { - // Regression: the queued path built runEmbeddedAgent params inline and - // dropped run.clientCaps, so capability-gated tools vanished after drain. - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - admitReplyTurnMock.mockResolvedValueOnce({ - status: "admitted", - operation: createReplyOperationForTest({ - sessionKey: "main", - sessionId: "session-client-caps", - resetTriggered: false, - }), - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", +function createQueuedRun(overrides: Partial = {}): FollowupRun { + return { + prompt: "queued prompt", + enqueuedAt: 1, + run: { + agentId: "agent", + agentDir: "/tmp/agent", + sessionId: "session", sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - run: { - sessionId: "session-client-caps", - sessionKey: "main", - provider: "anthropic", - model: "claude", - clientCaps: ["tool-events", "inline-widgets"], - }, - }), - ); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); - // Constrained CI workers charge this file's cold module-reset pause to its first test. - }, 300_000); - - it("adopts a matching admission-time model lock for queued execution", async () => { - const storePath = "/tmp/openclaw-followup-admission-model-lock.json"; - const queuedEntry: SessionEntry = { - sessionId: "catalog-adopted-session", - updatedAt: 1, - }; - const admittedEntry: SessionEntry = { - ...queuedEntry, - updatedAt: 2, - agentHarnessId: "codex", - modelSelectionLocked: true, - }; - registerFollowupTestSessionStore(storePath, { main: admittedEntry }); - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - model: { - fallbacks: ["openai/gpt-5.4-mini"], - }, - }, - }, - }; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: queuedEntry, - sessionStore: { main: queuedEntry }, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - run: { - config: runtimeConfig, - sessionId: queuedEntry.sessionId, - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - - const preflightCall = requireLastMockCallArg( - runPreflightCompactionIfNeededMock, - "preflight compaction", - ); - const preflightRun = requireRecord(preflightCall.followupRun, "preflight follow-up run"); - expect(requireRecord(preflightRun.run, "preflight run").modelSelectionLocked).toBe(true); - const fallbackCall = requireLastMockCallArg(runWithModelFallbackMock, "model fallback"); - expect(fallbackCall.fallbacksOverride).toEqual([]); - expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ - modelSelectionLocked: true, - agentHarnessId: "codex", - agentHarnessRuntimeOverride: "codex", - }); - }); - - it("keeps the queued model lock when the admission entry belongs to another session", async () => { - const replacementEntry: SessionEntry = { - sessionId: "replacement-session", - updatedAt: 2, - }; - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - model: { - fallbacks: ["openai/gpt-5.4-mini"], - }, - }, - }, - }; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: replacementEntry, - sessionStore: { main: replacementEntry }, - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - run: { - config: runtimeConfig, - sessionId: "queued-session", - sessionKey: "main", - provider: "anthropic", - model: "claude", - modelSelectionLocked: true, - }, - }), - ); - - const fallbackCall = requireLastMockCallArg(runWithModelFallbackMock, "model fallback"); - expect(fallbackCall.fallbacksOverride).toEqual([]); - expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ - sessionId: "queued-session", - modelSelectionLocked: true, - }); - }); - - it("awaits queued-owner admission before model execution", async () => { - const events: string[] = []; - let releaseAdmission!: () => void; - const admissionBarrier = new Promise((resolve) => { - releaseAdmission = resolve; - }); - runEmbeddedAgentMock.mockImplementationOnce(async () => { - events.push("run"); - return { payloads: [], meta: {} }; - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - const pending = runner( - createQueuedRun({ - turnAdoptionLifecycle: { - onAdopted: async () => { - events.push("admission-started"); - await admissionBarrier; - events.push("admitted"); - }, - onSettled: () => events.push("complete"), - admission: "exclusive", - onAbandoned: () => {}, - }, - run: { provider: "anthropic", model: "claude" }, - }), - ); - - await vi.waitFor(() => expect(events).toEqual(["admission-started"])); - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - - releaseAdmission(); - await pending; - - expect(events).toEqual(["admission-started", "admitted", "run", "complete"]); - }); - - it("stops an aborted queued followup after asynchronous owner admission", async () => { - const events: string[] = []; - const abortController = new AbortController(); - let releaseAdmission!: () => void; - const admissionBarrier = new Promise((resolve) => { - releaseAdmission = resolve; - }); - const onBlockReply = vi.fn(async () => {}); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - opts: { onBlockReply }, - }); - - const pending = runner( - createQueuedRun({ - abortSignal: abortController.signal, - turnAdoptionLifecycle: { - onAdopted: async () => { - events.push("admission-started"); - await admissionBarrier; - events.push("admitted"); - }, - onSettled: () => events.push("complete"), - admission: "exclusive", - onAbandoned: () => {}, - }, - run: { provider: "anthropic", model: "claude" }, - }), - ); - - await vi.waitFor(() => expect(events).toEqual(["admission-started"])); - abortController.abort(); - releaseAdmission(); - await pending; - - expect(events).toEqual(["admission-started", "admitted", "complete"]); - expect(runPreflightCompactionIfNeededMock).not.toHaveBeenCalled(); - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(runCliAgentMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it("passes prepared media user turns to embedded runtime dispatch", async () => { - const preparedUserTurnMessage = { - role: "user", - content: "describe this", - MediaPath: "/tmp/image.png", - MediaType: "image/png", - } as never; - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - userTurnTranscriptRecorder: createTestUserTurnRecorder(preparedUserTurnMessage), - run: { - provider: "anthropic", - model: "claude", - cwd: "/tmp/task-repo", - }, - }), - ); - - expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.cwd).toBe("/tmp/task-repo"); - const recorder = requireRecord(call.userTurnTranscriptRecorder, "embedded user turn recorder"); - expect(recorder.message).toBe(preparedUserTurnMessage); - }); - - it("runs queued followups with the session id returned by admission", async () => { - const active = createReplyOperationForTest({ - sessionKey: "main", - sessionId: "pre-compact-session", - resetTriggered: false, - }); - active.setPhase("preflight_compacting"); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { agentMeta: { provider: "anthropic", model: "claude" } }, - }); - const sessionStore = { - main: { - sessionId: "pre-compact-session", - sessionFile: "/tmp/pre-compact.jsonl", - updatedAt: Date.now(), - }, - }; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: sessionStore.main, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - const pending = runner( - createQueuedRun({ - run: { - sessionId: "queued-stale-session", - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - active.updateSessionId("post-compact-session"); - sessionStore.main = { - sessionId: "post-compact-session", - sessionFile: "/tmp/post-compact.jsonl", - updatedAt: Date.now(), - }; - active.complete(); - await pending; - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.sessionId).toBe("post-compact-session"); - expect(call.sessionFile).toBe("/tmp/post-compact.jsonl"); - }); - - it("marks only the delivery-dependent follow-up admission wait", async () => { - const waitChanges: boolean[] = []; - const active = createReplyOperationForTest({ - sessionKey: "main", - sessionId: "active-session", - resetTriggered: false, - }); - let releaseBarrier = () => {}; - const barrier = new Promise((resolve) => { - releaseBarrier = resolve; - }); - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - const pending = runner( - createQueuedRun({ - onReplyAdmissionWaitChange: (waiting) => waitChanges.push(waiting), - run: { - sessionId: "queued-session", - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - await Promise.resolve(); - expect(waitChanges).toEqual([]); - - active.completeWithAfterClearBarrier(barrier); - await vi.waitFor(() => { - expect(waitChanges).toEqual([true]); - }); - - releaseBarrier(); - await pending; - expect(waitChanges).toEqual([true, false]); - expect(runEmbeddedAgentMock).toHaveBeenCalledOnce(); - }); - - it("uses an admission session hint while refreshing the queued session file", async () => { - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { agentMeta: { provider: "anthropic", model: "claude" } }, - }); - const sessionStore = { - main: { - sessionId: "rotated-session", - sessionFile: "/tmp/rotated.jsonl", - updatedAt: Date.now(), - }, - }; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: sessionStore.main, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - admissionSessionId: "rotated-session", - run: { - sessionId: "queued-stale-session", - sessionFile: "/tmp/stale.jsonl", - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.sessionId).toBe("rotated-session"); - expect(call.sessionFile).toBe("/tmp/rotated.jsonl"); - }); - - it("registers the admitted session id when the local session store is stale", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const active = createReplyOperationForTest({ - sessionKey: "main", - sessionId: "pre-compact-session", - resetTriggered: false, - }); - active.setPhase("preflight_compacting"); - let observedRunId: string | undefined; - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { runId: string; sessionId?: string }) => { - observedRunId = params.runId; - expect(params.sessionId).toBe("post-compact-session"); - return { - payloads: [], - meta: { agentMeta: { provider: "anthropic", model: "claude" } }, - }; - }, - ); - const sessionStore = { - main: { - sessionId: "pre-compact-session", - sessionFile: "/tmp/pre-compact.jsonl", - updatedAt: Date.now(), - }, - }; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: sessionStore.main, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - const pending = runner( - createQueuedRun({ - run: { - sessionId: "queued-stale-session", - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - await new Promise((resolve) => { - setTimeout(resolve, 0); - }); - active.updateSessionId("post-compact-session"); - active.complete(); - await pending; - - expect(observedRunId).toBeDefined(); - expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe( - "post-compact-session", - ); - }); - - it("routes preflight compaction failures before starting queued followup runs", async () => { - runPreflightCompactionIfNeededMock.mockRejectedValueOnce( - new Error("Preflight compaction required but failed: auth profile mismatch"), - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - originatingChatType: "group", - run: { - messageProvider: "discord", - provider: "anthropic", - model: "claude", - verboseLevel: "off", - sessionKey: "main", - }, - }), - ); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledOnce(); - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "discord", - to: "channel:C1", - accountId: "acct-1", - threadId: "thread-1", - payload: expect.objectContaining({ - text: expect.stringContaining("auto-compaction could not recover"), - }), - }), - ); - }); - - it("suppresses preflight compaction failure notices for queued room events", async () => { - runPreflightCompactionIfNeededMock.mockRejectedValueOnce( - new Error("Preflight compaction required but failed: auth profile mismatch"), - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - originatingChatType: "group", - run: { - messageProvider: "discord", - provider: "anthropic", - model: "claude", - verboseLevel: "off", - sessionKey: "main", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), - ); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("preserves non-compaction preflight failures for queued followup runs", async () => { - runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed")); - const onComplete = vi.fn(); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - await expect( - runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - messageProvider: "discord", - provider: "anthropic", - model: "claude", - sessionKey: "main", - }, - turnAdoptionLifecycle: { onAdopted: async () => {}, onSettled: onComplete }, - }), - ), - ).rejects.toThrow("session load failed"); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onComplete).not.toHaveBeenCalled(); - }); -}); - -async function normalizeComparablePath(filePath: string): Promise { - const parent = await fs.realpath(path.dirname(filePath)).catch(() => path.dirname(filePath)); - return path.join(parent, path.basename(filePath)); -} - -function mockCompactionRun(params: { - willRetry: boolean; - result: { - payloads: Array<{ text: string }>; - meta: Record; - }; -}) { - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => void; - }) => { - args.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: params.willRetry, completed: true }, - }); - return params.result; - }, - ); -} - -function createAsyncReplySpy() { - return vi.fn(async () => {}); -} - -describe("createFollowupRunner auto fallback primary probes", () => { - it("clears queued auto fallback pins after a successful primary probe", async () => { - const sessionKey = "probe-clear"; - const sessionEntry: SessionEntry = { - sessionId: "session-1", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.4", - modelOverrideSource: "auto", - modelOverrideFallbackOriginProvider: "anthropic", - modelOverrideFallbackOriginModel: "claude", - }; - const sessionStore = { [sessionKey]: sessionEntry }; - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { agentMeta: { provider: "anthropic", model: "claude" } }, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey, - defaultModel: "anthropic/claude", - }); - - await runner( - createQueuedRun({ - run: { - sessionKey, - provider: "anthropic", - model: "claude", - autoFallbackPrimaryProbe: { - provider: "anthropic", - model: "claude", - fallbackProvider: "openai", - fallbackModel: "gpt-5.4", - }, - }, - }), - ); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.provider).toBe("anthropic"); - expect(call.model).toBe("claude"); - expect(sessionEntry.providerOverride).toBeUndefined(); - expect(sessionEntry.modelOverride).toBeUndefined(); - expect(sessionEntry.modelOverrideSource).toBeUndefined(); - expect(sessionEntry.modelOverrideFallbackOriginProvider).toBeUndefined(); - expect(sessionEntry.modelOverrideFallbackOriginModel).toBeUndefined(); - }); - - it("rechecks queued probe throttle and keeps fallback auth when probe is not due", async () => { - const sessionKey = "probe-skip"; - const probe = { + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, provider: "anthropic", model: "claude", - fallbackProvider: "openai", - fallbackModel: "gpt-5.4", - fallbackAuthProfileId: "openai:fallback", - fallbackAuthProfileIdSource: "auto" as const, - }; - const sessionEntry: SessionEntry = { - sessionId: "session-1", - updatedAt: Date.now(), - providerOverride: "openai", - modelOverride: "gpt-5.4", - modelOverrideSource: "auto", - modelOverrideFallbackOriginProvider: "anthropic", - modelOverrideFallbackOriginModel: "claude", - authProfileOverride: "openai:fallback", - authProfileOverrideSource: "auto", - }; - const sessionStore = { [sessionKey]: sessionEntry }; - const { markAutoFallbackPrimaryProbe } = await import("../../agents/agent-scope.js"); - markAutoFallbackPrimaryProbe({ probe, sessionKey }); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { agentMeta: { provider: "openai", model: "gpt-5.4" } }, - }); - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { followupRun: FollowupRun; sessionEntry?: SessionEntry }) => { - expect(params.followupRun.run.provider).toBe("openai"); - expect(params.followupRun.run.model).toBe("gpt-5.4"); - expect(params.followupRun.run.autoFallbackPrimaryProbe).toBeUndefined(); - return params.sessionEntry; - }, - ); + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + ...overrides, + }; +} - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey, - defaultModel: "anthropic/claude", - }); +function createTypingController() { + return { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + isActive: vi.fn(() => false), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), + cleanup: vi.fn(), + }; +} - await runner( - createQueuedRun({ - run: { - sessionKey, - provider: "anthropic", - model: "claude", - authProfileId: "anthropic:primary", - authProfileIdSource: "auto", - autoFallbackPrimaryProbe: probe, - }, +function createTurn( + order: string[] = [], + result: AdmittedFollowupTurn["operation"]["result"] = null, +) { + const operation = { + result, + complete: vi.fn(() => order.push("operation-complete")), + fail: vi.fn(() => order.push("operation-failed")), + }; + return { + runId: "run-1", + queued: createQueuedRun(), + operation, + config: {}, + session: { + kind: "session", + key: "main", + current: () => undefined, + publish: vi.fn(), + }, + sendPolicy: "allow", + preflightCompactionApplied: false, + } as unknown as AdmittedFollowupTurn & { operation: typeof operation }; +} + +function createRejectedExecution(order: string[] = []): FollowupExecutionResult { + return { + execution: { + runId: "run-1", + outcome: { kind: "rejected", payload: { text: "failed" } }, + }, + runStartedAt: 1, + sessionCtx: {}, + pendingToolTasks: new Set(), + progress: { + drain: vi.fn(async () => { + order.push("progress-drained"); }), - ); + visibleToolErrorObserved: () => false, + }, + } as FollowupExecutionResult; +} - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.provider).toBe("openai"); - expect(call.model).toBe("gpt-5.4"); - expect(call.authProfileId).toBe("openai:fallback"); - expect(call.authProfileIdSource).toBe("auto"); - expect(sessionEntry.providerOverride).toBe("openai"); - expect(sessionEntry.modelOverride).toBe("gpt-5.4"); - expect(sessionEntry.modelOverrideSource).toBe("auto"); - }); +beforeEach(() => { + vi.clearAllMocks(); + state.completedSourceDelivery = false; + state.resolveDecision.mockReturnValue({ kind: "suppress", reason: "silent" }); }); -describe("createFollowupRunner runtime config", () => { - it("keeps a locked Codex harness pinned when a CLI backend shares its id", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const sessionEntry: SessionEntry = { - sessionId: "catalog-adopted-session", - updatedAt: Date.now(), - agentHarnessId: "codex", - agentRuntimeOverride: "claude-cli", - modelSelectionLocked: true, - pluginExtensions: { - codex: { - supervision: { - sourceThreadId: "019f-codex-thread", - modelLocked: true, - }, - }, - }, - }; - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); +describe("createFollowupRunner", () => { + it("completes lifecycle and both typing signals for an already-aborted item", async () => { + const typing = createTypingController(); + const controller = new AbortController(); + controller.abort(); + const queued = createQueuedRun({ abortSignal: controller.signal }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore: { main: sessionEntry }, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })(queued); - await runner( - createQueuedRun({ - run: { - config: runtimeConfig, - sessionId: sessionEntry.sessionId, - sessionKey: "main", - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - expect(runCliAgentMock).not.toHaveBeenCalled(); - expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ - provider: "anthropic", - model: "claude-opus-4-7", - agentHarnessId: "codex", - agentHarnessRuntimeOverride: "codex", - }); + expect(state.admit).not.toHaveBeenCalled(); + expect(state.completeLifecycle).toHaveBeenCalledWith(queued); + expect(typing.markRunComplete).toHaveBeenCalledOnce(); + expect(typing.markDispatchIdle).toHaveBeenCalledOnce(); }); - it("routes queued followups through CLI runtime dispatch when the model selects a CLI backend", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const sessionEntry: SessionEntry = { - sessionId: "session-cli-followup", - updatedAt: Date.now(), - cliSessionBindings: { - "claude-cli": { - sessionId: "cli-session-1", - }, - }, - }; - const sessionStore = { main: sessionEntry }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - }, - }, - }); + it("turns active-lane deferral into a restorable queue error", async () => { + const typing = createTypingController(); + const queued = createQueuedRun(); + state.admit.mockResolvedValue({ kind: "deferred", reason: "active-run" }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); + await expect( + createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })(queued), + ).rejects.toBeInstanceOf(FollowupRunDeferredError); - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "telegram:-100123:topic:42", - originatingThreadId: "42", - originatingReplyToId: "reply-42", - messageId: "queued-message-1", - run: { - config: runtimeConfig, - sessionId: "session-cli-followup", - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - clientCaps: ["tool-events", "inline-widgets"], - senderId: "sender-42", - senderName: "Sender 42", - senderUsername: "sender-42-user", - senderE164: "+15550003333", - senderIsOwner: true, - execOverrides: { host: "node", node: "mac-b" }, - bashElevated: { enabled: true, allowed: true, defaultLevel: "ask" }, - groupId: "group-42", - groupChannel: "ops", - groupSpace: "workspace-42", - spawnedBy: "agent:main:telegram:group:parent", - runtimePolicySessionKey: "agent:agent:telegram:default:direct:sender-42", - cwd: "/tmp/task-repo", - inputProvenance: { - kind: "internal_system", - sourceChannel: "telegram", - sourceTool: "restart-sentinel", - }, - }, - }), - ); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - const call = requireLastMockCallArg(runCliAgentMock, "run cli agent"); - expect(call.provider).toBe("claude-cli"); - expect(call.modelProvider).toBe("anthropic"); - expect(call.model).toBe("claude-opus-4-7"); - expect(call.config).toBe(runtimeConfig); - expect(call.cliSessionId).toBe("cli-session-1"); - expect(call.messageChannel).toBe("telegram"); - expect(call.clientCaps).toEqual(["tool-events", "inline-widgets"]); - expect(call.currentChannelId).toBe("telegram:-100123:topic:42"); - expect(call.currentThreadTs).toBe("42"); - expect(call.currentMessageId).toBe("reply-42"); - expect(call.senderId).toBe("sender-42"); - expect(call.senderName).toBe("Sender 42"); - expect(call.senderUsername).toBe("sender-42-user"); - expect(call.senderE164).toBe("+15550003333"); - expect(call.senderIsOwner).toBe(true); - expect(call.execOverrides).toEqual({ host: "node", node: "mac-b" }); - expect(call.bashElevated).toEqual({ enabled: true, allowed: true, defaultLevel: "ask" }); - expect(call.groupId).toBe("group-42"); - expect(call.groupChannel).toBe("ops"); - expect(call.groupSpace).toBe("workspace-42"); - expect(call.spawnedBy).toBe("agent:main:telegram:group:parent"); - expect(call.runtimePolicySessionKey).toBe("agent:agent:telegram:default:direct:sender-42"); - expect(call).toMatchObject({ - sessionId: "session-cli-followup", - sessionKey: "main", - agentId: "agent", - workspaceDir: "/tmp", - cwd: "/tmp/task-repo", - config: runtimeConfig, - suppressNextUserMessagePersistence: false, - }); - expect(call.onUserMessagePersisted).toEqual(expect.any(Function)); + expect(state.completeLifecycle).not.toHaveBeenCalled(); + expect(typing.markRunComplete).toHaveBeenCalledOnce(); + expect(typing.markDispatchIdle).toHaveBeenCalledOnce(); }); - it("bridges queued CLI thinking events into reasoning stream progress", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const onReasoningStream = vi.fn< - NonNullable - >(async () => {}); - runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => { - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-cli-followup-reasoning", - stream: "thinking", - data: { text: "checking files", isReasoningSnapshot: true }, - }); - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-cli-followup-reasoning", - stream: "thinking", - data: { text: "checking tests" }, - }); - return { payloads: [], meta: { agentMeta: { provider: "claude-cli" } } }; + it("releases an operation acquired before asynchronous admission cancellation", async () => { + const order: string[] = []; + const typing = createTypingController(); + const turn = createTurn(order); + state.admit.mockResolvedValue({ + kind: "skipped", + reason: "aborted", + operation: turn.operation, }); - const runner = createFollowupRunner({ - opts: { onReasoningStream }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "user_request", - originatingChannel: "telegram", - run: { - config: runtimeConfig, - messageProvider: "telegram", - provider: "anthropic", - model: "claude-opus-4-7", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })( + turn.queued, ); - expect(onReasoningStream.mock.calls.map((call) => call[0])).toEqual([ - { - text: "checking files", - isReasoningSnapshot: true, - requiresReasoningProgressOptIn: true, - }, - { - text: "checking tests", - requiresReasoningProgressOptIn: true, - }, - ]); + expect(order).toEqual(["operation-complete"]); + expect(state.completeLifecycle).toHaveBeenCalledWith(turn.queued); }); - it("reuses CLI session bindings for queued room-event followups", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const sessionEntry: SessionEntry = { - sessionId: "session-cli-room-event", - updatedAt: Date.now(), - cliSessionBindings: { - "claude-cli": { - sessionId: "cli-session-1", - }, - }, - }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - cliSessionBinding: { - sessionId: "cli-session-1", - }, - }, - }, - }); + it("restores unexpected execution failures after releasing the admitted operation", async () => { + const order: string[] = []; + const typing = createTypingController(); + const turn = createTurn(order); + const failure = new Error("candidate failed before settlement"); + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockRejectedValue(failure); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore: { main: sessionEntry }, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); + await expect( + createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })(turn.queued), + ).rejects.toBe(failure); - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - currentInboundAudio: true, - currentInboundContext: { text: "[OpenClaw room event]" }, - run: { - config: runtimeConfig, - sessionId: "session-cli-room-event", - provider: "anthropic", - model: "claude-opus-4-7", - suppressNextUserMessagePersistence: true, - sourceReplyDeliveryMode: "message_tool_only", - taskSuggestionDeliveryMode: "gateway", - allowEmptyAssistantReplyAsSilent: true, - }, - }), - ); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(runCliAgentMock).toHaveBeenCalledOnce(); - const call = requireLastMockCallArg(runCliAgentMock, "run cli agent"); - expect(call.currentInboundEventKind).toBe("room_event"); - expect(call.persistAssistantTranscript).toBe(false); - expect(call.currentInboundAudio).toBe(true); - expect(call.suppressNextUserMessagePersistence).toBe(true); - expect(call.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect(call.taskSuggestionDeliveryMode).toBe("gateway"); - expect(call.allowEmptyAssistantReplyAsSilent).toBe(true); - expect(call.cliSessionId).toBe("cli-session-1"); - expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" }); + expect(state.completeLifecycle).not.toHaveBeenCalled(); + expect(state.clearRunContext).toHaveBeenCalledWith("run-1"); + expect(order).toEqual(["operation-complete"]); + expect(typing.markRunComplete).toHaveBeenCalledOnce(); + expect(typing.markDispatchIdle).toHaveBeenCalledOnce(); }); - it("stores queued room-event CLI sessions created from the first ambient run", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const storePath = "/tmp/openclaw-followup-room-event-cli.json"; - const sessionEntry: SessionEntry = { - sessionId: "session-cli-room-event", - updatedAt: Date.now(), - }; - const sessionStore = { main: sessionEntry }; - registerFollowupTestSessionStore(storePath, sessionStore); - runCliAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - sessionId: "cli-session-1", - cliSessionBinding: { - sessionId: "cli-session-1", - authProfileId: "profile", - }, - }, - }, - }); + it("consumes a user abort before execution starts", async () => { + const typing = createTypingController(); + const turn = createTurn([], { kind: "aborted", code: "aborted_by_user" }); + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockRejectedValue(new Error("aborted before execution start")); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - currentInboundContext: { text: "[OpenClaw room event]" }, - run: { - config: runtimeConfig, - sessionId: "session-cli-room-event", - provider: "anthropic", - model: "claude-opus-4-7", - suppressNextUserMessagePersistence: true, - sourceReplyDeliveryMode: "message_tool_only", - }, - }), + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })( + turn.queued, ); - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(runCliAgentMock).toHaveBeenCalledOnce(); - const call = requireLastMockCallArg(runCliAgentMock, "run cli agent"); - expect(call.currentInboundEventKind).toBe("room_event"); - expect(call.cliSessionId).toBeUndefined(); - expect(sessionStore.main.cliSessionBindings?.["claude-cli"]).toEqual({ - sessionId: "cli-session-1", - authProfileId: "profile", - }); + expect(state.completeLifecycle).toHaveBeenCalledWith(turn.queued); + expect(state.clearRunContext).toHaveBeenCalledWith("run-1"); + expect(turn.operation.fail).not.toHaveBeenCalled(); }); - it("does not replace queued room-event CLI session bindings when reuse fails", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const sessionEntry: SessionEntry = { - sessionId: "session-cli-room-event", - updatedAt: Date.now(), - cliSessionBindings: { - "claude-cli": { - sessionId: "cli-session-1", - }, - }, - }; - const sessionStore = { main: sessionEntry }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - sessionId: "transient-cli-session", - cliSessionBinding: { - sessionId: "transient-cli-session", - }, - }, - }, + it("consumes a turn that fails after canonical execution starts", async () => { + const typing = createTypingController(); + const turn = createTurn(); + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockImplementation(async ({ onExecutionStarted }) => { + onExecutionStarted?.(); + throw new Error("execution failed after start"); }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - currentInboundContext: { text: "[OpenClaw room event]" }, - run: { - config: runtimeConfig, - sessionId: "session-cli-room-event", - provider: "anthropic", - model: "claude-opus-4-7", - suppressNextUserMessagePersistence: true, - sourceReplyDeliveryMode: "message_tool_only", - }, - }), + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })( + turn.queued, ); - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(runCliAgentMock).toHaveBeenCalledOnce(); - const call = requireLastMockCallArg(runCliAgentMock, "run cli agent"); - expect(call.currentInboundEventKind).toBe("room_event"); - expect(call.cliSessionId).toBe("cli-session-1"); - expect(call.cliSessionBinding).toEqual({ sessionId: "cli-session-1" }); - expect(sessionStore.main.cliSessionBindings?.["claude-cli"]).toBeUndefined(); + expect(state.execute).toHaveBeenCalledOnce(); + expect(state.completeLifecycle).toHaveBeenCalledWith(turn.queued); + expect(state.clearRunContext).toHaveBeenCalledWith("run-1"); + expect(turn.operation.fail).toHaveBeenCalledWith("run_failed", expect.any(Error)); }); - it("passes prepared media user turns to CLI runtime dispatch", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const preparedUserTurnMessage = { - role: "user", - content: "describe this", - MediaPath: "/tmp/image.png", - MediaType: "image/png", - } as never; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "done" }], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - storePath: "/tmp/sessions.json", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - userTurnTranscriptRecorder: createTestUserTurnRecorder(preparedUserTurnMessage), - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - expect(runCliAgentMock).toHaveBeenCalledOnce(); - const mediaCall = requireLastMockCallArg(runCliAgentMock, "run cli agent"); - expect(mediaCall.persistAssistantTranscript).toBe(true); - expect(mediaCall.storePath).toBe("/tmp/sessions.json"); - const recorder = requireRecord(mediaCall.userTurnTranscriptRecorder, "cli user turn recorder"); - expect(recorder.message).toBe(preparedUserTurnMessage); - }); - - it("disables routed delivery mirrors for CLI-owned followup payloads", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [ - setReplyPayloadMetadataForTest( - { text: "persisted CLI followup" }, - { assistantTranscriptOwned: true }, - ), - ], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "telegram:-100123", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - expect(runCliAgentMock).toHaveBeenCalledOnce(); - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { text: "persisted CLI followup" }, - mirror: false, - }), - ); - }); - - it("does not deliver durable reasoning for a queued CLI followup when reasoning payloads are disabled", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "internal reasoning", isReasoning: true }, { text: "final answer" }], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - opts: { reasoningPayloadsEnabled: false }, - }); - - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "telegram:-100123", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { text: "final answer" }, - }), - ); - expect( - routeReplyMock.mock.calls.some((call) => { - const payload = requireRecord( - requireRecord(call[0], "route reply params").payload, - "payload", - ); - return payload.isReasoning === true; - }), - ).toBe(false); - }); - - // Resolver-level gate, not an end-to-end delivery proof: route-reply.js is - // mocked above (routeReplyMock), but resolveFollowupDeliveryPayloads is the - // REAL implementation, so this still proves the gate itself fires correctly - // for a queued CLI followup — the runner only forwards a reasoning payload - // to routing when opts.reasoningPayloadsEnabled is true. Whether the - // real routeReply then delivers it is a separate, pre-existing question: - // routeReply unconditionally suppresses isReasoning payloads on the - // origin-routing branch (route-reply.ts:131, shouldSuppressReasoningPayload, - // predates this change, shared with the embedded runner) and that - // suppression is intentionally out of scope here — see route-reply.test.ts. - it("passes the durable reasoning payload through to routing for a queued CLI followup when reasoning payloads are enabled", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runCliAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "internal reasoning", isReasoning: true }, { text: "final answer" }], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - opts: { reasoningPayloadsEnabled: true }, - }); - - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "telegram:-100123", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - // Proves the resolver kept the reasoning payload (it survived - // resolveFollowupDeliveryPayloads) and the runner routed it — not that a - // real channel received it (routeReply is mocked; see comment above). - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { text: "internal reasoning", isReasoning: true }, - }), - ); - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: { text: "final answer" }, - }), - ); - }); - - it("keeps queued CLI tool progress quiet when verbose progress is disabled", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const onToolStart = vi.fn(async () => {}); - runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => { - realAgentEvents.emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { phase: "start", name: "web_search", args: { query: "hidden" } }, - }); - return { - payloads: [{ text: "final" }], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - }, - }, - }; - }); - - const runner = createFollowupRunner({ - opts: { onToolStart }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - originatingChannel: "telegram", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onToolStart).not.toHaveBeenCalled(); - }); - - it("bridges queued CLI preambles for progress headlines when commentary is disabled", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const onItemEvent = vi.fn(async () => {}); - runCliAgentMock.mockImplementationOnce( - async (params: { runId: string; emitCommentaryText?: boolean }) => { - expect(params.emitCommentaryText).toBe(true); - realAgentEvents.emitAgentEvent({ - runId: params.runId, - stream: "item", - data: { - kind: "preamble", - itemId: "commentary-1", - progressText: "Let me check the files.", - }, - }); - return { - payloads: [{ text: "final" }], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - }, - }, - }; - }, - ); - - const runner = createFollowupRunner({ - opts: { - onItemEvent, - commentaryProgressEnabled: false, - progressPreambleEnabled: true, - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - originatingChannel: "telegram", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onItemEvent).toHaveBeenCalledWith( - expect.objectContaining({ - kind: "preamble", - progressText: "Let me check the files.", - itemId: "commentary-1", - }), - ); - }); - - it("starts queued CLI tool presentation before later commentary", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - const callbackOrder: string[] = []; - const onToolStart = vi.fn(async () => { - callbackOrder.push("tool"); - }); - const onItemEvent = vi.fn(async () => { - callbackOrder.push("commentary"); - }); - runCliAgentMock.mockImplementationOnce(async (params: { runId: string }) => { - realAgentEvents.emitAgentEvent({ - runId: params.runId, - stream: "tool", - data: { - phase: "start", - name: "exec", - toolCallId: "tool-1", - args: { command: "pwd" }, - }, - }); - realAgentEvents.emitAgentEvent({ - runId: params.runId, - stream: "item", - data: { - kind: "preamble", - itemId: "commentary-1", - progressText: "Checking the result.", - }, - }); - return { - payloads: [], - meta: { agentMeta: { provider: "claude-cli", model: "claude-opus-4-7" } }, - }; - }); - - const runner = createFollowupRunner({ - opts: { - onToolStart, - onItemEvent, - commentaryProgressEnabled: true, - preserveProgressCallbackStartOrder: true, - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - originatingChannel: undefined, - originatingTo: undefined, - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: undefined, - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }), - ); - - expect(callbackOrder).toEqual(["tool", "commentary"]); - }); - - it("defers queued CLI attempt terminal lifecycle events until fallback settles", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const lifecyclePhases: string[] = []; - const unsubscribe = realAgentEvents.onAgentEvent((evt) => { - if (evt.stream !== "lifecycle") { - return; - } - const phase = typeof evt.data.phase === "string" ? evt.data.phase : undefined; - if (phase) { - lifecyclePhases.push(phase); - } - }); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("cli failed"); - return { - result: await params.run("openai", "gpt-5.4"), - provider: "openai", - model: "gpt-5.4", - }; - }, - ); - runCliAgentMock.mockRejectedValueOnce(new Error("cli failed")); - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { - runId: string; - deferTerminalLifecycle?: boolean; - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - expect(params.deferTerminalLifecycle).toBe(true); - const startedAt = Date.now(); - const startEvent = { - stream: "lifecycle", - data: { phase: "start", startedAt }, - }; - realAgentEvents.emitAgentEvent({ - runId: params.runId, - ...startEvent, - }); - await params.onAgentEvent?.(startEvent); - const finishingEvent = { - stream: "lifecycle", - data: { phase: "finishing", endedAt: Date.now() }, - }; - realAgentEvents.emitAgentEvent({ - runId: params.runId, - ...finishingEvent, - }); - await params.onAgentEvent?.(finishingEvent); - return { - payloads: [{ text: "fallback ok" }], - meta: {}, - }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - try { - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - }, - }), - ); - } finally { - unsubscribe(); - } - - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); - const embeddedCall = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(embeddedCall.suppressAssistantErrorPersistence).toBe(false); - expect(lifecyclePhases).toEqual(["start", "start", "finishing", "end"]); - }); - - it("revalidates immutable Ultra for embedded and CLI followup fallback candidates", async () => { - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } }, - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - resolveProviderThinkingProfileMock.mockImplementation(({ provider }: { provider: string }) => { - if (provider === "openai") { - return { levels: [{ id: "ultra" }] }; - } - if (provider === "anthropic") { - return { levels: [{ id: "max" }] }; - } + it("holds the reply operation through progress drain, accounting, and delivery", async () => { + const order: string[] = []; + const typing = createTypingController(); + const turn = createTurn(order); + const execution = createRejectedExecution(order); + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockResolvedValue(execution); + state.account.mockImplementation(async () => { + order.push("accounted"); return undefined; }); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - await params.run("openai", "gpt-5.6-sol"); - return { - result: await params.run("anthropic", "claude-opus-4-7"), - provider: "anthropic", - model: "claude-opus-4-7", - }; - }, - ); - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - runCliAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const queued = createQueuedRun({ - run: { - config: runtimeConfig, - provider: "openai", - model: "gpt-5.6-sol", - thinkLevel: "ultra", - }, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.6-sol", - }); - - await runner(queued); - - expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent").thinkLevel).toBe( - "ultra", - ); - expect(requireLastMockCallArg(runCliAgentMock, "run cli agent").thinkLevel).toBe("max"); - expect(queued.run.thinkLevel).toBe("ultra"); - }); - - it("delivers an exhausted embedded followup as a failed lifecycle", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const lifecycleEvents: Array> = []; - const unsubscribe = realAgentEvents.onAgentEvent((evt) => { - if (evt.stream === "lifecycle") { - lifecycleEvents.push(evt.data); - } - }); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => ({ - outcome: "exhausted", - result: await params.run("anthropic", "claude-opus-4-7"), - provider: "anthropic", - model: "claude-opus-4-7", - }), - ); - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { - deferTerminalLifecycle?: boolean; - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - expect(params.deferTerminalLifecycle).toBe(true); - await params.onAgentEvent?.({ - stream: "lifecycle", - data: { phase: "start", startedAt: 1_000 }, - }); - await params.onAgentEvent?.({ - stream: "lifecycle", - data: { phase: "finishing", endedAt: 1_500 }, - }); - return { - payloads: [{ text: "Terminal tool summary", isError: true }], - meta: { - error: { - kind: "incomplete_turn", - message: "raw exhausted provider detail should stay private", - }, - }, - }; - }, - ); - let operationResultDuringCompletion: - | import("./reply-run-registry.js").ReplyOperation["result"] - | undefined; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - try { - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - turnAdoptionLifecycle: { - onAdopted: async () => {}, - onSettled: () => { - operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result; - }, - }, - run: { - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - }, - }), - ); - } finally { - unsubscribe(); - } - - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ text: "Terminal tool summary", isError: true }), - }), - ); - expect(operationResultDuringCompletion).toMatchObject({ - kind: "failed", - code: "run_failed", - }); - expect(lifecycleEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "error", - startedAt: 1_000, - fallbackExhaustedFailure: true, - }), - ]), - ); - expect(lifecycleEvents.some((event) => event.phase === "end")).toBe(false); - expect(JSON.stringify(lifecycleEvents)).not.toContain("raw exhausted provider detail"); - }); - - it("delivers a completed non-fallbackable error followup as a failed lifecycle", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const lifecycleEvents: Array> = []; - const unsubscribe = realAgentEvents.onAgentEvent((evt) => { - if (evt.stream === "lifecycle") { - lifecycleEvents.push(evt.data); - } - }); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => ({ - outcome: "completed", - result: await params.run("anthropic", "claude-opus-4-7"), - provider: "anthropic", - model: "claude-opus-4-7", - }), - ); - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await params.onAgentEvent?.({ - stream: "lifecycle", - data: { - phase: "finishing", - error: "Command may have changed state", - replayInvalid: true, - }, - }); - return { - payloads: [{ text: "Command may have changed state", isError: true }], - meta: { - replayInvalid: true, - error: { - kind: "incomplete_turn", - message: "raw provider detail should stay private", - fallbackSafe: false, - }, - }, - }; - }, - ); - let operationResultDuringCompletion: - | import("./reply-run-registry.js").ReplyOperation["result"] - | undefined; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - try { - await runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - turnAdoptionLifecycle: { - onAdopted: async () => {}, - onSettled: () => { - operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result; - }, - }, - run: { - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - }, - }), - ); - } finally { - unsubscribe(); - } - - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - payload: expect.objectContaining({ - text: "Command may have changed state", - isError: true, - }), - }), - ); - expect(operationResultDuringCompletion).toMatchObject({ - kind: "failed", - code: "run_failed", - }); - expect(lifecycleEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "error", - error: "Command may have changed state", - replayInvalid: true, - }), - ]), - ); - expect( - lifecycleEvents.some( - (event) => event.phase === "end" || event.fallbackExhaustedFailure === true, - ), - ).toBe(false); - expect(JSON.stringify(lifecycleEvents)).not.toContain("raw provider detail"); - }); - - it("suppresses deferred CLI success after restart cancellation", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const lifecycleEvents: Array> = []; - const unsubscribe = realAgentEvents.onAgentEvent((evt) => { - if (evt.stream === "lifecycle") { - lifecycleEvents.push(evt.data); - } - }); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - let resolveCli: (() => void) | undefined; - runCliAgentMock.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveCli = () => - resolve({ - payloads: [{ text: "completed after restart" }], - meta: { - agentMeta: { - provider: "claude-cli", - model: "claude-opus-4-7", - }, - }, - }); - }), - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-7", - }); - - try { - const pending = runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - run: { - config: runtimeConfig, - provider: "anthropic", - model: "claude-opus-4-7", - messageProvider: "telegram", - }, - }), - ); - await vi.waitFor(() => { - expect(runCliAgentMock).toHaveBeenCalledTimes(1); - }); - expect(abortActiveReplyRunsForTest({ mode: "all" })).toBe(true); - resolveCli?.(); - await pending; - } finally { - unsubscribe(); - } - - expect(lifecycleEvents).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - phase: "end", - aborted: true, - stopReason: "restart", - }), - ]), - ); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("uses the active runtime snapshot for queued embedded followup runs", async () => { - const sourceConfig: OpenClawConfig = { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: { - source: "env", - provider: "default", - id: "OPENAI_API_KEY", - }, - models: [], - }, - }, - }, - }; - const runtimeConfig: OpenClawConfig = { - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: "resolved-runtime-key", - models: [], - }, - }, - }, - }; - setRuntimeConfigSnapshot(runtimeConfig, sourceConfig); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - - await runner( - createQueuedRun({ - run: { - config: sourceConfig, - provider: "openai", - model: "gpt-5.4", - }, - }), - ); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.config).toBe(runtimeConfig); - }); - - it("skips aborted queued room-event followups", async () => { - const abortController = new AbortController(); - abortController.abort(); - const onBlockReply = vi.fn(async () => {}); - const typing = createMockTypingController(); - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing, - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - abortSignal: abortController.signal, - run: { - provider: "openai", - model: "gpt-5.4", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), - ); - - expect(runEmbeddedAgentMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - expect(typing.markRunComplete).toHaveBeenCalledTimes(1); - expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); - }); - - it("passes the admitted reply abort signal into followup fallback and agent runs", async () => { - const abortController = new AbortController(); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - currentInboundAudio: true, - abortSignal: abortController.signal, - run: { - provider: "openai", - model: "gpt-5.4", - sourceReplyDeliveryMode: "message_tool_only", - taskSuggestionDeliveryMode: "gateway", - }, - }), - ); - - const fallbackCall = requireLastMockCallArg( - runWithModelFallbackMock, - "run with model fallback", - ); - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal); - expect(fallbackCall.abortSignal).not.toBe(abortController.signal); - expect(fallbackCall.sessionId).toBe("session"); - expect(call.abortSignal).toBe(fallbackCall.abortSignal); - expect(call.currentInboundAudio).toBe(true); - expect(call.taskSuggestionDeliveryMode).toBe("gateway"); - }); - - it("does not inherit source abort signals for queued user followups", async () => { - const sourceAbortController = new AbortController(); - sourceAbortController.abort(); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - opts: { abortSignal: sourceAbortController.signal }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "user_request", - run: { - provider: "openai", - model: "gpt-5.4", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), - ); - - const fallbackCall = requireLastMockCallArg( - runWithModelFallbackMock, - "run with model fallback", - ); - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(fallbackCall.abortSignal).toBeInstanceOf(AbortSignal); - expect(fallbackCall.abortSignal).not.toBe(sourceAbortController.signal); - expect(call.abortSignal).toBe(fallbackCall.abortSignal); - }); - - it("suppresses a settled followup result after an accepted user abort", async () => { - let releaseFallback: () => void = () => undefined; - let releaseProgressRoute: () => void = () => undefined; - let markCandidateSettled: () => void = () => undefined; - const candidateSettled = new Promise((resolve) => { - markCandidateSettled = resolve; - }); - const fallbackRelease = new Promise((resolve) => { - releaseFallback = resolve; - }); - const progressRouteStarted = new Promise((resolve) => { - routeReplyMock.mockImplementationOnce( - async () => - await new Promise<{ ok: true }>((release) => { - releaseProgressRoute = () => release({ ok: true }); - resolve(); - }), - ); - }); - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { onToolResult?: (payload: { text: string }) => Promise }) => { - void args.onToolResult?.({ text: "queued progress" }); - return { - payloads: [{ text: "late followup" }], - meta: {}, - }; - }, - ); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { - run: ( - provider: string, - model: string, - options?: { isFinalFallbackAttempt?: boolean }, - ) => Promise<{ - payloads: Array<{ text: string }>; - meta: object; - }>; - }) => { - const result = await params.run("openai", "gpt-5.4", { - isFinalFallbackAttempt: false, - }); - markCandidateSettled(); - await fallbackRelease; - return { - result, - provider: "openai", - model: "gpt-5.4", - }; - }, - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "openai/gpt-5.4", - }); - - const pending = runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - run: { - provider: "openai", - model: "gpt-5.4", - messageProvider: "telegram", - verboseLevel: "on", - }, - }), - ); - await candidateSettled; - await progressRouteStarted; - expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent")).toMatchObject({ - isFinalFallbackAttempt: false, - }); - expect(replyRunRegistryForTest.abort("main")).toBe(true); - releaseFallback(); - let settled = false; - void pending.then(() => { - settled = true; - }); - await Promise.resolve(); - expect(settled).toBe(false); - releaseProgressRoute(); - await pending; - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ - text: "queued progress", - }); - }); - - it("keeps a direct cancellation error from becoming a followup failure", async () => { - let rejectAttempt: (error: Error) => void = () => undefined; - let markAttemptStarted: () => void = () => undefined; - const attemptStarted = new Promise((resolve) => { - markAttemptStarted = resolve; - }); - runEmbeddedAgentMock.mockImplementationOnce( - () => - new Promise((_resolve, reject) => { - rejectAttempt = reject; - markAttemptStarted(); - }), - ); - let operationResultDuringCompletion: - | import("./reply-run-registry.js").ReplyOperation["result"] - | undefined; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "openai/gpt-5.4", - }); - - const pending = runner( - createQueuedRun({ - originatingChannel: "telegram", - originatingTo: "chat-1", - turnAdoptionLifecycle: { - onAdopted: async () => {}, - onSettled: () => { - operationResultDuringCompletion = replyRunRegistryForTest.get("main")?.result; - }, - }, - run: { - provider: "openai", - model: "gpt-5.4", - messageProvider: "telegram", - }, - }), - ); - await attemptStarted; - expect(replyRunRegistryForTest.abort("main")).toBe(true); - rejectAttempt(Object.assign(new Error("agent run aborted"), { name: "AbortError" })); - await pending; - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(operationResultDuringCompletion).toEqual({ - kind: "aborted", - code: "aborted_by_user", - }); - }); - - it("keeps queued delivery correlations active during followup agent runs", async () => { - const events: string[] = []; - runEmbeddedAgentMock.mockImplementationOnce(async () => { - events.push("run"); - return { - payloads: [], - meta: {}, - }; - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - deliveryCorrelations: [ - { - begin: () => { - events.push("begin"); - return () => { - events.push("end"); - }; - }, - }, - ], - run: { - provider: "openai", - model: "gpt-5.4", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), - ); - - expect(events).toEqual(["begin", "run", "end"]); - }); - - it("notifies the active dispatcher after queued followup admission", async () => { - const events: string[] = []; - runEmbeddedAgentMock.mockImplementationOnce(async () => { - events.push("run"); - return { payloads: [], meta: {} }; - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - opts: { - onQueuedFollowupAdmitted: () => { - events.push("admitted"); - }, - }, - }); - - await runner(createQueuedRun()); - - expect(events).toEqual(["admitted", "run"]); - }); - - it("resolves queued embedded followups before preflight helpers read config", async () => { - const sourceConfig: OpenClawConfig = { - skills: { - entries: { - whisper: { - apiKey: { - source: "env", - provider: "default", - id: "OPENAI_API_KEY", - }, - }, - }, - }, - }; - const runtimeConfig: OpenClawConfig = { - skills: { - entries: { - whisper: { - apiKey: "resolved-runtime-key", - }, - }, - }, - }; - resolveCommandSecretRefsViaGatewayMock.mockResolvedValueOnce({ - resolvedConfig: runtimeConfig, - diagnostics: [], - targetStatesByPath: { "skills.entries.whisper.apiKey": "resolved_local" }, - hadUnresolvedTargets: false, - }); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - const queued = createQueuedRun({ - run: { - config: sourceConfig, - provider: "openai", - model: "gpt-5.4", - }, - }); - - await runner(queued); - - expect(queued.run.config).toBe(runtimeConfig); - expect(requireMockCallArg(runPreflightCompactionIfNeededMock, 0).cfg).toBe(runtimeConfig); - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.config).toBe(runtimeConfig); - }); - - it("passes queued origin scope into queued execution-config resolution", async () => { - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const sourceConfig: OpenClawConfig = {}; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - }); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingAccountId: "work", - run: { - config: sourceConfig, - provider: "openai", - model: "gpt-5.4", - messageProvider: "discord", - agentAccountId: "bot-account", - }, - }); - - await runner(queued); - - expect(resolveQueuedReplyExecutionConfigMock).toHaveBeenCalledWith(sourceConfig, { - originatingChannel: "discord", - messageProvider: "discord", - originatingAccountId: "work", - agentAccountId: "bot-account", - }); - }); - - it("passes queued images into queued embedded followup runs", async () => { - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const images = [{ type: "image" as const, data: "base64-cat", mimeType: "image/png" }]; - const imageOrder = ["inline" as const]; - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.4", - opts: { - images: [{ type: "image", data: "fallback", mimeType: "image/png" }], - imageOrder: ["inline"], - }, - }); - - await runner( - createQueuedRun({ - images, - imageOrder, - }), - ); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.images).toBe(images); - expect(call.imageOrder).toBe(imageOrder); - }); -}); - -describe("createFollowupRunner progress forwarding", () => { - it("records queued thread id on follow-up reply operations", async () => { - const queued = createQueuedRun({ - originatingChannel: "slack", - originatingTo: "user:U1", - originatingThreadId: "501.000", - run: { - messageProvider: "slack", - }, - }); - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { replyOperation?: { routeThreadId?: string | number } }) => { - expect(args.replyOperation?.routeThreadId).toBe("501.000"); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner(queued); - - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); - }); - - it("forwards queued follow-up tool progress and verbose tool result payloads", async () => { - const onToolStart = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - onToolResult?: (payload: { text: string }) => Promise; - shouldEmitToolResult?: () => boolean; - shouldEmitToolOutput?: () => boolean; - toolProgressDetail?: "explain" | "raw"; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(true); - expect(args.shouldEmitToolOutput?.()).toBe(false); - expect(args.toolProgressDetail).toBe("raw"); - await args.onAgentEvent?.({ - stream: "tool", - data: { - itemId: "tool:queued-progress", - toolCallId: "queued-progress", - phase: "start", - name: "exec", - args: { command: "echo queued-progress" }, - }, - }); - await args.onToolResult?.({ text: "🛠️ Exec: echo queued-progress" }); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onToolStart }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - toolProgressDetail: "raw", - }); - - await runner(queued); - - expect(onToolStart).toHaveBeenCalledWith({ - itemId: "tool:queued-progress", - toolCallId: "queued-progress", - name: "exec", - phase: "start", - args: { command: "echo queued-progress" }, - detailMode: "raw", - }); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "discord", - to: "channel:C1", - accountId: "acct-1", - threadId: "thread-1", - mirror: false, - replyKind: "tool", - payload: expect.objectContaining({ text: "🛠️ Exec: echo queued-progress" }), - }), - ); - }); - - it("keeps queued room-event verbose tool summaries suppressed", async () => { - const queued = createQueuedRun({ - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onToolResult?: (payload: { text: string }) => Promise; - shouldEmitToolResult?: () => boolean; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(true); - await args.onToolResult?.({ text: "🛠️ Exec: echo ambient-progress" }); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner(queued); - - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it.each([ - [ - "delivers queued fast auto progress for non-room-event message-tool-only turns", - "user_request", - true, - ], - [ - "suppresses queued fast auto progress for room-event message-tool-only turns", - "room_event", - false, - ], - ] as const)("%s", async (_name, currentInboundEventKind, shouldDeliverProgress) => { - vi.useFakeTimers(); - vi.setSystemTime(1_000); - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const runtimeConfig: OpenClawConfig = { - agents: { - defaults: { - models: { - "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } }, - }, - }, - }, - }; - runCliAgentMock.mockImplementationOnce((params: { runId?: string }) => { - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-fast-followup", - stream: "tool", - data: { phase: "start", name: "bash", toolCallId: "call-1" }, - }); - vi.setSystemTime(7_100); - realAgentEvents.emitAgentEvent({ - runId: params.runId ?? "run-fast-followup", - stream: "tool", - data: { phase: "result", name: "bash", toolCallId: "call-1" }, - }); - return { payloads: [], meta: { agentMeta: {} } }; - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind, - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - config: runtimeConfig, - messageProvider: "discord", - provider: "anthropic", - model: "claude-opus-4-7", - sourceReplyDeliveryMode: "message_tool_only", - fastMode: "auto", - fastModeOverride: true, - fastModeAutoOnSeconds: 5, - fastModeAutoOnSecondsOverride: true, - }, - }), - ); - - if (!shouldDeliverProgress) { - expect(routeReplyMock).not.toHaveBeenCalled(); - return; - } - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "discord", - to: "channel:C1", - accountId: "acct-1", - threadId: "thread-1", - mirror: false, - replyKind: "tool", - payload: expect.objectContaining({ - text: "💨Fast: auto-off(6s>=5s)", - channelData: { openclawProgressKind: "fast-mode-auto" }, - }), - }), - ); - }); - - it("drains fire-and-forget queued tool progress before final delivery", async () => { - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - verboseLevel: "on", - }, - }); - let releaseProgressRoute: (() => void) | undefined; - const progressRouteStarted = new Promise((resolve) => { - routeReplyMock.mockImplementationOnce( - async () => - await new Promise<{ ok: true }>((release) => { - releaseProgressRoute = () => { - release({ ok: true }); - }; - resolve(); - }), - ); - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { onToolResult?: (payload: { text: string }) => Promise }) => { - void args.onToolResult?.({ text: "🛠️ Exec: echo queued-progress" }); - return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - const runPromise = runner(queued); - await progressRouteStarted; - await Promise.resolve(); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(requireMockCallArg(routeReplyMock, 0).payload).toEqual( - expect.objectContaining({ text: "🛠️ Exec: echo queued-progress" }), - ); - expect(requireMockCallArg(routeReplyMock, 0).mirror).toBe(false); - - releaseProgressRoute?.(); - await runPromise; - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - expect(requireMockCallArg(routeReplyMock, 1).payload).toEqual( - expect.objectContaining({ text: "final reply" }), - ); - expect(requireMockCallArg(routeReplyMock, 1).mirror).toBeUndefined(); - }); - - it("preserves queued verbose progress when default tool progress is suppressed", async () => { - const onToolStart = vi.fn(async () => {}); - const onItemEvent = vi.fn(async () => {}); - const onCommandOutput = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - onToolResult?: (payload: { text: string }) => Promise; - shouldEmitToolResult?: () => boolean; - shouldEmitToolOutput?: () => boolean; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(true); - expect(args.shouldEmitToolOutput?.()).toBe(false); - await args.onAgentEvent?.({ - stream: "tool", - data: { - phase: "start", - name: "exec", - args: { command: "echo queued-suppressed-preview" }, - }, - }); - await args.onAgentEvent?.({ - stream: "item", - data: { - itemId: "command:queued-suppressed-preview", - toolCallId: "queued-suppressed-preview", - kind: "command", - name: "exec", - phase: "update", - status: "running", - progressText: "queued output", - }, - }); - await args.onAgentEvent?.({ - stream: "command_output", - data: { phase: "chunk", output: "queued output" }, - }); - await args.onToolResult?.({ text: "🛠️ Exec: echo queued-suppressed-preview" }); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { - suppressDefaultToolProgressMessages: true, - onToolStart, - onItemEvent, - onCommandOutput, - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - toolProgressDetail: "raw", - }); - - await runner(queued); - - expect(onToolStart).toHaveBeenCalledWith({ - name: "exec", - phase: "start", - args: { command: "echo queued-suppressed-preview" }, - detailMode: "raw", - }); - expect(onItemEvent).toHaveBeenCalledWith( - expect.objectContaining({ - itemId: "command:queued-suppressed-preview", - toolCallId: "queued-suppressed-preview", - kind: "command", - name: "exec", - phase: "update", - }), - ); - expect(onCommandOutput).toHaveBeenCalledWith( - expect.objectContaining({ phase: "chunk", output: "queued output" }), - ); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock).toHaveBeenCalledWith( - expect.objectContaining({ - channel: "discord", - to: "channel:C1", - accountId: "acct-1", - threadId: "thread-1", - mirror: false, - payload: expect.objectContaining({ text: "🛠️ Exec: echo queued-suppressed-preview" }), - }), - ); - }); - - it.each([ - [ - "forwards queued Codex command tool results as command output completion", - [ - { - stream: "tool", - data: { - phase: "result", - itemId: "command:queued-exec", - toolCallId: "queued-exec", - name: "exec", - status: "completed", - result: { exitCode: 0, durationMs: 24 }, - }, - }, - ], - { - itemId: "command:queued-exec", - phase: "end", - title: undefined, - toolCallId: "queued-exec", - name: "exec", - output: undefined, - status: "completed", - exitCode: 0, - durationMs: 24, - cwd: undefined, - }, - true, - ], - [ - "marks queued Codex command tool result errors as failed command output", - [ - { - stream: "tool", - data: { - phase: "result", - itemId: "command:queued-exec", - toolCallId: "queued-exec", - name: "exec", - isError: true, - result: { content: [{ type: "text", text: "command failed" }] }, - }, - }, - ], - { - itemId: "command:queued-exec", - phase: "end", - toolCallId: "queued-exec", - name: "exec", - status: "failed", - }, - false, - ], - [ - "does not synthesize queued command output from bare exec tool results", - [ - { - stream: "tool", - data: { - phase: "result", - name: "exec", - toolCallId: "queued-exec", - isError: false, - }, - }, - { - stream: "command_output", - data: { - itemId: "command:queued-exec", - phase: "end", - title: "command ls", - toolCallId: "queued-exec", - name: "exec", - status: "completed", - exitCode: 0, - }, - }, - ], - { itemId: "command:queued-exec", phase: "end", status: "completed" }, - false, - ], - ] as const)("%s", async (_name, events, expectedCommandOutput, expectExactCall) => { - const onCommandOutput = vi.fn(async () => {}); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "acct-1", - originatingThreadId: "thread-1", - run: { - messageProvider: "discord", - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - for (const event of events) { - await args.onAgentEvent?.(event); - } - return { payloads: [{ text: "final reply" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onCommandOutput }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner(queued); - - expect(onCommandOutput).toHaveBeenCalledTimes(1); - if (expectExactCall) { - expect(onCommandOutput).toHaveBeenCalledWith(expectedCommandOutput); - return; - } - expect(onCommandOutput).toHaveBeenCalledWith(expect.objectContaining(expectedCommandOutput)); - }); - - it("suppresses queued follow-up progress when verbose progress is disabled", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-followup-progress-off-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - }; - const sessionStore: Record = { main: sessionEntry }; - const onToolStart = vi.fn(async () => {}); - const onItemEvent = vi.fn(async () => {}); - const onCommandOutput = vi.fn(async () => {}); - const onCompactionStart = vi.fn(async () => {}); - const onCompactionEnd = vi.fn(async () => {}); - registerFollowupTestSessionStore(storePath, sessionStore); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - shouldEmitToolResult?: () => boolean; - shouldEmitToolOutput?: () => boolean; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(false); - expect(args.shouldEmitToolOutput?.()).toBe(false); - await args.onAgentEvent?.({ - stream: "tool", - data: { phase: "start", name: "exec", args: { command: "echo hidden" } }, - }); - await args.onAgentEvent?.({ - stream: "item", - data: { phase: "start", itemId: "item-1", title: "hidden item" }, - }); - await args.onAgentEvent?.({ - stream: "command_output", - data: { phase: "chunk", output: "hidden output" }, - }); - await args.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", completed: true }, - }); - return { payloads: [{ text: "final" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onToolStart, onItemEvent, onCommandOutput, onCompactionStart, onCompactionEnd }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onToolStart).not.toHaveBeenCalled(); - expect(onItemEvent).not.toHaveBeenCalled(); - expect(onCommandOutput).not.toHaveBeenCalled(); - expect(onCompactionStart).not.toHaveBeenCalled(); - expect(onCompactionEnd).not.toHaveBeenCalled(); - expect( - expectDefined(sessionStore.main, "sessionStore.main test invariant").compactionCount, - ).toBe(1); - }); - - it("forwards opted-in queued tool lifecycle feedback while verbose progress is disabled", async () => { - const onToolStart = vi.fn(async () => {}); - const onItemEvent = vi.fn(async () => {}); - const onCommandOutput = vi.fn(async () => {}); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ - stream: "tool", - data: { phase: "start", name: "exec", args: { command: "echo hidden" } }, - }); - await args.onAgentEvent?.({ - stream: "item", - data: { phase: "start", itemId: "item-1", title: "hidden item" }, - }); - await args.onAgentEvent?.({ - stream: "command_output", - data: { phase: "chunk", output: "hidden output" }, - }); - return { payloads: [{ text: "final" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { - allowToolLifecycleWhenProgressHidden: true, - onToolStart, - onItemEvent, - onCommandOutput, - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onToolStart).toHaveBeenCalledWith({ - name: "exec", - phase: "start", - args: { command: "echo hidden" }, - detailMode: undefined, - }); - expect(onItemEvent).not.toHaveBeenCalled(); - expect(onCommandOutput).not.toHaveBeenCalled(); - }); - - it("keeps internal tool lifecycle events out of queued channel progress", async () => { - const onToolStart = vi.fn(async () => {}); - const onItemEvent = vi.fn(async () => {}); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ - stream: "tool", - data: { - phase: "start", - name: "wait", - hideFromChannelProgress: true, - }, - }); - await args.onAgentEvent?.({ - stream: "item", - data: { - phase: "start", - itemId: "tool:wait-1", - title: "wait", - hideFromChannelProgress: true, - }, - }); - return { payloads: [{ text: "final" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { - allowToolLifecycleWhenProgressHidden: true, - onToolStart, - onItemEvent, - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onToolStart).not.toHaveBeenCalled(); - expect(onItemEvent).not.toHaveBeenCalled(); - }); - - it("keeps queued follow-up progress quiet when verbose state is missing", async () => { - const onToolStart = vi.fn(async () => {}); - const onCommandOutput = vi.fn(async () => {}); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - onToolResult?: (payload: { text: string }) => Promise; - shouldEmitToolResult?: () => boolean; - shouldEmitToolOutput?: () => boolean; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(false); - expect(args.shouldEmitToolOutput?.()).toBe(false); - await args.onAgentEvent?.({ - stream: "tool", - data: { phase: "start", name: "exec", args: { command: "echo hidden" } }, - }); - await args.onAgentEvent?.({ - stream: "command_output", - data: { phase: "chunk", output: "hidden output" }, - }); - await args.onToolResult?.({ text: "🛠️ Exec: echo hidden" }); - return { payloads: [{ text: "final" }], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { suppressDefaultToolProgressMessages: false, onToolStart, onCommandOutput }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: undefined, - }, - }), - ); - - expect(onToolStart).not.toHaveBeenCalled(); - expect(onCommandOutput).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("does not reuse dispatch-scoped tool-error suppression across queued follow-ups", async () => { - const onCommandOutput = vi.fn(async () => {}); - - runEmbeddedAgentMock - .mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - suppressToolErrorWarnings?: boolean | (() => boolean | undefined); - }) => { - const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined; - expect(shouldSuppress()).toBeUndefined(); - await args.onAgentEvent?.({ - stream: "command_output", - data: { - phase: "end", - name: "exec", - status: "failed", - exitCode: 1, - }, - }); - expect(shouldSuppress()).toBe(true); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ) - .mockImplementationOnce( - async (args: { suppressToolErrorWarnings?: boolean | (() => boolean | undefined) }) => { - const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined; - expect(shouldSuppress()).toBe(false); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onCommandOutput, shouldSuppressToolErrorWarnings: () => true }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }), - ); - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "off", - }, - }), - ); - - expect(onCommandOutput).toHaveBeenCalledTimes(1); - }); - - it.each([ - [ - "keeps queued tool-error fallbacks when the channel declines failed progress", - "on", - "declines", - ], - [ - "keeps queued full-verbose tool-error fallbacks available after failed progress", - "full", - "accepts", - ], - ["keeps queued tool-error fallbacks when failed progress has no callback", "on", "missing"], - ] as const)("%s", async (_name, verboseLevel, callbackMode) => { - const onCommandOutput = - callbackMode === "missing" - ? undefined - : vi.fn(async () => (callbackMode === "declines" ? (false as const) : undefined)); - let completedAfterEvent = false; - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - suppressToolErrorWarnings?: boolean | (() => boolean | undefined); - }) => { - const shouldSuppress = args.suppressToolErrorWarnings as () => boolean | undefined; - expect(shouldSuppress()).toBeUndefined(); - await args.onAgentEvent?.({ - stream: "command_output", - data: { - phase: "end", - name: "exec", - status: "failed", - exitCode: 1, - }, - }); - expect(shouldSuppress()).toBeUndefined(); - completedAfterEvent = true; - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: onCommandOutput ? { onCommandOutput } : undefined, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel, - }, - }), - ); - - if (onCommandOutput) { - expect(onCommandOutput).toHaveBeenCalledTimes(1); - } - expect(completedAfterEvent).toBe(true); - }); - - it("uses current session verbose state for queued follow-up progress", async () => { - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - verboseLevel: "off", - }; - const sessionStore: Record = { main: sessionEntry }; - const onToolStart = vi.fn(async () => {}); - - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - shouldEmitToolResult?: () => boolean; - }) => { - expect(args.shouldEmitToolResult?.()).toBe(false); - await args.onAgentEvent?.({ - stream: "tool", - data: { phase: "start", name: "exec", args: { command: "echo hidden" } }, - }); - return { payloads: [], meta: { agentMeta: {} } }; - }, - ); - - const runner = createFollowupRunner({ - opts: { onToolStart }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - defaultModel: "claude", - }); - - await runner( - createQueuedRun({ - run: { - messageProvider: "discord", - sessionKey: "main", - sourceReplyDeliveryMode: "message_tool_only", - verboseLevel: "on", - }, - }), - ); - - expect(onToolStart).not.toHaveBeenCalled(); - }); -}); - -describe("createFollowupRunner compaction", () => { - it("adds verbose auto-compaction notice and tracks count", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - }; - const sessionStore: Record = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - registerFollowupTestSessionStore(storePath, sessionStore); - - mockCompactionRun({ - willRetry: true, - result: { payloads: [{ text: "final" }], meta: {} }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - }); - - const queued = createQueuedRun({ - run: { - verboseLevel: "on", - }, - }); - - await runner(queued); - - expect(onBlockReply).toHaveBeenCalledTimes(2); - const firstCall = (onBlockReply.mock.calls as unknown as Array>)[0]; - expect(firstCall?.[0]?.text).toContain("Auto-compaction complete"); - expect( - expectDefined(sessionStore.main, "sessionStore.main test invariant").compactionCount, - ).toBe(1); - }); - - it("suppresses queued auto-compaction notice when verbose is turned off", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-quiet-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - verboseLevel: "off", - }; - const sessionStore: Record = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - registerFollowupTestSessionStore(storePath, sessionStore); - - mockCompactionRun({ - willRetry: true, - result: { payloads: [{ text: "final" }], meta: {} }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - }); - - const queued = createQueuedRun({ - run: { - verboseLevel: "on", - }, - }); - - await runner(queued); - - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectNoBlockReplyTextIncludes(onBlockReply, "Auto-compaction complete"); - expect( - expectDefined(sessionStore.main, "sessionStore.main test invariant").compactionCount, - ).toBe(1); - }); - - it("tracks auto-compaction from embedded result metadata even when no compaction event is emitted", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-meta-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - sessionFile: path.join(path.dirname(storePath), "session.jsonl"), - updatedAt: Date.now(), - }; - const sessionStore: Record = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - registerFollowupTestSessionStore(storePath, sessionStore); - - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "final" }], - meta: { - agentMeta: { - sessionId: "session-rotated", - compactionCount: 2, - lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 }, - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - }); - - const queued = createQueuedRun({ - run: { - verboseLevel: "on", - }, - }); - - await runner(queued); - - expect(onBlockReply).toHaveBeenCalledTimes(2); - const firstCall = (onBlockReply.mock.calls as unknown as Array>)[0]; - expect(firstCall?.[0]?.text).toContain("Auto-compaction complete"); - expect( - expectDefined(sessionStore.main, "sessionStore.main test invariant").compactionCount, - ).toBe(2); - expect(expectDefined(sessionStore.main, "sessionStore.main test invariant").sessionId).toBe( - "session-rotated", - ); - expect( - await normalizeComparablePath( - expectDefined(sessionStore.main, "sessionStore.main test invariant").sessionFile ?? "", - ), - ).toBe( - await normalizeComparablePath(path.join(path.dirname(storePath), "session-rotated.jsonl")), - ); - }); - - it("refreshes queued followup runs to the rotated transcript", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-queue-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - sessionFile: path.join(path.dirname(storePath), "session.jsonl"), - updatedAt: Date.now(), - }; - const sessionStore: Record = { - main: sessionEntry, - }; - registerFollowupTestSessionStore(storePath, sessionStore); - - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "final" }], - meta: { - agentMeta: { - sessionId: "session-rotated", - compactionCount: 1, - lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 }, - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - }); - - const queuedNext = createQueuedRun({ - prompt: "next", - run: { - sessionId: "session", - sessionFile: path.join(path.dirname(storePath), "session.jsonl"), - }, - }); - const queueSettings: QueueSettings = { mode: "followup" }; - enqueueFollowupRun("main", queuedNext, queueSettings); - - const current = createQueuedRun({ - run: { - verboseLevel: "on", - sessionId: "session", - sessionFile: path.join(path.dirname(storePath), "session.jsonl"), - }, - }); - - await runner(current); - - expect(queuedNext.run.sessionId).toBe("session-rotated"); - expect(await normalizeComparablePath(queuedNext.run.sessionFile)).toBe( - await normalizeComparablePath(path.join(path.dirname(storePath), "session-rotated.jsonl")), - ); - }); - - it("does not count failed compaction end events in followup runs", async () => { - const storePath = path.join( - await fs.mkdtemp(path.join(tmpdir(), "openclaw-compaction-failed-")), - "sessions.json", - ); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - }; - const sessionStore: Record = { - main: sessionEntry, - }; - const onBlockReply = vi.fn(async () => {}); - registerFollowupTestSessionStore(storePath, sessionStore); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - }); - - const queued = createQueuedRun({ - run: { - verboseLevel: "on", - }, - }); - - runEmbeddedAgentMock.mockImplementationOnce(async (args) => { - args.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry: false, completed: false }, - }); - return { - payloads: [{ text: "final" }], - meta: { - agentMeta: { - compactionCount: 0, - lastCallUsage: { input: 10_000, output: 3_000, total: 13_000 }, - }, - }, - }; - }); - - await runner(queued); - - expect(onBlockReply).toHaveBeenCalledTimes(1); - const firstCall = (onBlockReply.mock.calls as unknown as Array>)[0]; - expect(firstCall?.[0]?.text).toBe("final"); - expect( - expectDefined(sessionStore.main, "sessionStore.main test invariant").compactionCount, - ).toBeUndefined(); - }); - - it("injects the post-compaction refresh prompt before followup runs after preflight compaction", async () => { - const workspaceDir = await fs.mkdtemp(path.join(tmpdir(), "openclaw-preflight-followup-")); - const storePath = path.join(workspaceDir, "sessions.json"); - const transcriptPath = path.join(workspaceDir, "session.jsonl"); - await fs.writeFile( - transcriptPath, - `${JSON.stringify({ - message: { - role: "user", - content: "x".repeat(320_000), - timestamp: Date.now(), - }, - })}\n`, - "utf-8", - ); - await fs.writeFile( - path.join(workspaceDir, "AGENTS.md"), - [ - "## Session Startup", - "Read AGENTS.md before replying.", - "", - "## Red Lines", - "Never skip safety checks.", - ].join("\n"), - "utf-8", - ); - - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - sessionFile: transcriptPath, - totalTokens: 10, - totalTokensFresh: false, - compactionCount: 1, - }; - const sessionStore: Record = { - main: sessionEntry, - }; - registerFollowupTestSessionStore(storePath, sessionStore); - const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - - compactEmbeddedAgentSessionMock.mockResolvedValueOnce({ - ok: true, - compacted: true, - result: { - summary: "compacted", - firstKeptEntryId: "first-kept", - tokensBefore: 90_000, - tokensAfter: 8_000, - }, - }); - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { - followupRun: FollowupRun; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; - }) => { - await compactEmbeddedAgentSessionMock({ - sessionFile: transcriptPath, - workspaceDir, - }); - params.followupRun.run.extraSystemPrompt = joinPromptSections( - params.followupRun.run.extraSystemPrompt, - "Post-compaction context refresh", - "Read AGENTS.md before replying.", - ); - const updatedEntry = - params.sessionEntry ?? - (params.sessionKey && params.sessionStore - ? params.sessionStore[params.sessionKey] - : undefined); - if (updatedEntry) { - updatedEntry.compactionCount = 2; - updatedEntry.updatedAt = Date.now(); - if (params.sessionKey && params.sessionStore) { - params.sessionStore[params.sessionKey] = updatedEntry; - } - if (params.storePath && params.sessionKey) { - const registeredStore = FOLLOWUP_TEST_SESSION_STORES.get(params.storePath); - if (registeredStore) { - registeredStore[params.sessionKey] = updatedEntry; - } else { - replaceSessionEntrySync( - { storePath: params.storePath, sessionKey: params.sessionKey }, - updatedEntry, - ); - } - } - } - return updatedEntry; - }, - ); - - const embeddedCalls: Array<{ extraSystemPrompt?: string }> = []; - runEmbeddedAgentMock.mockImplementationOnce(async (params: { extraSystemPrompt?: string }) => { - embeddedCalls.push({ extraSystemPrompt: params.extraSystemPrompt }); - return { - payloads: [{ text: "final" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - storePath, - defaultModel: "anthropic/claude-opus-4-6", - agentCfgContextTokens: 100_000, - }); - - const queued = createQueuedRun({ - run: { - sessionFile: transcriptPath, - workspaceDir, - }, - }); - - await runner(queued); - - expect(compactEmbeddedAgentSessionMock).toHaveBeenCalledOnce(); - expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Post-compaction context refresh"); - expect(embeddedCalls[0]?.extraSystemPrompt).toContain("Read AGENTS.md before replying."); - expect(sessionStore.main?.compactionCount).toBe(2); - expect(requireMockCallArg(persistSpy, 0).preserveFreshTotalTokensOnStaleUsage).toBe(true); - persistSpy.mockRestore(); - }); - - it("registers the post-preflight session id for lifecycle event stamping", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const sessionEntry: SessionEntry = { - sessionId: "old-session", - updatedAt: Date.now(), - sessionFile: "/tmp/old-session.jsonl", - }; - const sessionStore: Record = { - main: sessionEntry, - }; - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { - followupRun: FollowupRun; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - }) => { - const updatedEntry: SessionEntry = { - ...(params.sessionEntry ?? sessionEntry), - sessionId: "new-session", - sessionFile: "/tmp/new-session.jsonl", - updatedAt: Date.now(), - }; - params.followupRun.run.sessionId = updatedEntry.sessionId; - params.followupRun.run.sessionFile = "/tmp/new-session.jsonl"; - if (params.sessionKey && params.sessionStore) { - params.sessionStore[params.sessionKey] = updatedEntry; - } - return updatedEntry; - }, - ); - - let observedRunId: string | undefined; - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { runId: string; sessionId?: string }) => { - observedRunId = params.runId; - expect(params.sessionId).toBe("new-session"); - return { - payloads: [{ text: "final" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }, - ); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(createQueuedRun()); - - expect(observedRunId).toBeDefined(); - expect(realAgentEvents.getAgentRunContext(observedRunId ?? "")?.sessionId).toBe("new-session"); - }); - - it("captures follow-up lifecycle ownership before asynchronous preflight", async () => { - const realAgentEvents = await vi.importActual( - "../../infra/agent-events.js", - ); - const initialGeneration = realAgentEvents.getAgentEventLifecycleGeneration(); - let releasePreflight: (() => void) | undefined; - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { sessionEntry?: SessionEntry }) => { - await new Promise((resolve) => { - releasePreflight = resolve; - }); - return params.sessionEntry; - }, - ); - let observedLifecycleGeneration: string | undefined; - runEmbeddedAgentMock.mockImplementationOnce( - async (params: { lifecycleGeneration?: string }) => { - observedLifecycleGeneration = params.lifecycleGeneration; - if (params.lifecycleGeneration !== realAgentEvents.getAgentEventLifecycleGeneration()) { - const error = new Error("Agent run belongs to a stale gateway lifecycle"); - error.name = "AbortError"; - throw error; - } - return { - payloads: [{ text: "final" }], - meta: { agentMeta: { usage: { input: 1, output: 1 } } }, - }; - }, - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry: { - sessionId: "preflight-session", - updatedAt: Date.now(), - }, - sessionKey: "main", - defaultModel: "anthropic/claude", - }); - - try { - const pending = runner( - createQueuedRun({ - run: { - sessionId: "preflight-session", - sessionKey: "main", - provider: "anthropic", - model: "claude", - }, - }), - ); - await vi.waitFor(() => { - expect(runPreflightCompactionIfNeededMock).toHaveBeenCalledTimes(1); - }); - const [registeredRun] = realAgentEvents.listAgentRunsForSession({ - sessionKey: "main", - sessionId: "preflight-session", - }); - expect(registeredRun).toEqual( - expect.objectContaining({ - lifecycleGeneration: initialGeneration, - }), - ); - - realAgentEvents.rotateAgentEventLifecycleGeneration(); - releasePreflight?.(); - await pending; - - expect(observedLifecycleGeneration).toBe(initialGeneration); - expect(realAgentEvents.getAgentRunContext(registeredRun?.runId ?? "")).toBeUndefined(); - } finally { - } - }); -}); - -describe("createFollowupRunner bootstrap warning dedupe", () => { - it("passes stored warning signature history to embedded followup runs", async () => { - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - systemPromptReport: { - source: "run", - generatedAt: Date.now(), - systemPrompt: { - chars: 1, - projectContextChars: 0, - nonProjectContextChars: 1, - }, - injectedWorkspaceFiles: [], - skills: { - promptChars: 0, - entries: [], - }, - tools: { - listChars: 0, - schemaChars: 0, - entries: [], - }, - bootstrapTruncation: { - warningMode: "once", - warningShown: true, - promptWarningSignature: "sig-b", - warningSignaturesSeen: ["sig-a", "sig-b"], - truncatedFiles: 1, - nearLimitFiles: 0, - totalNearLimit: false, - }, - }, - }; - const sessionStore: Record = { main: sessionEntry }; - - const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, - typing: createMockTypingController(), - typingMode: "instant", - sessionEntry, - sessionStore, - sessionKey: "main", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(baseQueuedRun()); - - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.allowGatewaySubagentBinding).toBe(true); - expect(call.bootstrapPromptWarningSignaturesSeen).toEqual(["sig-a", "sig-b"]); - expect(call.bootstrapPromptWarningSignature).toBe("sig-b"); - }); -}); - -describe("createFollowupRunner messaging delivery and dedupe", () => { - function createMessagingDedupeRunner( - onBlockReply: (payload: unknown) => Promise, - overrides: Partial<{ - sessionEntry: SessionEntry; - sessionStore: Record; - sessionKey: string; - storePath: string; - opts: GetReplyOptions; - onObservedReplyDelivery: () => Promise; - }> = {}, - ) { - if (overrides.storePath && overrides.sessionStore) { - registerFollowupTestSessionStore(overrides.storePath, overrides.sessionStore); - } - return createFollowupRunner({ - opts: { - ...overrides.opts, - onBlockReply, - ...(overrides.onObservedReplyDelivery - ? { onObservedReplyDelivery: overrides.onObservedReplyDelivery } - : {}), - }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - sessionEntry: overrides.sessionEntry, - sessionStore: overrides.sessionStore, - sessionKey: overrides.sessionKey, - storePath: overrides.storePath, - }); - } - - async function runMessagingCase(params: { - agentResult: Record; - queued?: FollowupRun; - runnerOverrides?: Partial<{ - sessionEntry: SessionEntry; - sessionStore: Record; - sessionKey: string; - storePath: string; - opts: GetReplyOptions; - onObservedReplyDelivery: () => Promise; - }>; - agentEvent?: { stream: string; data: Record }; - }) { - const onBlockReply = createAsyncReplySpy(); - const agentResult = { - meta: {}, - ...params.agentResult, - }; - if (params.agentEvent) { - runEmbeddedAgentMock.mockImplementationOnce(async (runParams: unknown) => { - const onAgentEvent = requireRecord(runParams, "embedded run params").onAgentEvent; - if (typeof onAgentEvent !== "function") { - throw new Error("expected embedded run onAgentEvent callback"); - } - await onAgentEvent(params.agentEvent); - return agentResult; - }); - } else { - runEmbeddedAgentMock.mockResolvedValueOnce(agentResult); - } - const runner = createMessagingDedupeRunner(onBlockReply, params.runnerOverrides); - await runner(params.queued ?? baseQueuedRun()); - return { onBlockReply }; - } - - function makeTextReplyDedupeResult(overrides?: Record) { - return { - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - ...overrides, - }; - } - - it("persists usage even when replies are suppressed", async () => { - const storePath = "/tmp/openclaw-followup-usage.json"; - const sessionKey = "main"; - const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - registerFollowupTestSessionStore(storePath, sessionStore); - const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - persistSpy.mockImplementationOnce(async (params) => { - const nextEntry: SessionEntry = { - ...expectDefined(sessionStore[sessionKey], "sessionStore[sessionKey] test invariant"), - updatedAt: Date.now(), - totalTokens: params.lastCallUsage?.input, - totalTokensFresh: true, - model: params.modelUsed, - modelProvider: params.providerUsed, - inputTokens: params.usage?.input, - outputTokens: params.usage?.output, - }; - sessionStore[sessionKey] = nextEntry; - Object.assign(sessionEntry, nextEntry); - }); - - const { onBlockReply } = await runMessagingCase({ - agentResult: { - ...makeTextReplyDedupeResult({ messagingToolSentTexts: ["hello world!"] }), - messagingToolSentTargets: [{ tool: "slack", provider: "slack", to: "channel:C1" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - lastCallUsage: { input: 400, output: 20 }, - model: "claude-opus-4-6", - provider: "anthropic", - }, - }, - }, - runnerOverrides: { - sessionEntry, - sessionStore, - sessionKey, - storePath, - }, - queued: baseQueuedRun("slack"), - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - const persistCall = requireMockCallArg(persistSpy, 0); - expect(persistCall.storePath).toBe(storePath); - expect(persistCall.sessionKey).toBe(sessionKey); - expect(persistCall.modelUsed).toBe("claude-opus-4-6"); - expect(persistCall.providerUsed).toBe("anthropic"); - expect(sessionStore[sessionKey]?.totalTokens).toBe(400); - expect(sessionStore[sessionKey]?.model).toBe("claude-opus-4-6"); - // Accumulated usage is still stored for usage/cost tracking. - expect(sessionStore[sessionKey]?.inputTokens).toBe(1_000); - expect(sessionStore[sessionKey]?.outputTokens).toBe(50); - persistSpy.mockRestore(); - }); - - it("passes queued config into usage persistence during drained followups", async () => { - const storePath = "/tmp/openclaw-followup-usage-cfg.json"; - const sessionKey = "main"; - const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - registerFollowupTestSessionStore(storePath, sessionStore); - - const cfg = { - channels: { - slack: { responsePrefix: "agent" }, - }, - }; - const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 10, output: 5 }, - lastCallUsage: { input: 6, output: 3 }, - model: "claude-opus-4-6", - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: createAsyncReplySpy() }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - - await expect( - runner( - createQueuedRun({ - run: { - config: cfg, - }, - }), - ), - ).resolves.toBeUndefined(); - - const persistCall = requireMockCallArg(persistSpy, 0); - expect(persistCall.storePath).toBe(storePath); - expect(persistCall.sessionKey).toBe(sessionKey); - expect(persistCall.cfg).toBe(cfg); - persistSpy.mockRestore(); - }); - - it.each([ - [ - "appends configured responseUsage footers during followup delivery", - "main", - "tokens", - undefined, - undefined, - ["hello world!", "Usage:", "out"], - undefined, - undefined, - ], - [ - "renders full responseUsage followup footers without exposing the session key", - "discord:channel:user", - "full", - undefined, - "model={model.display_name} tokens={usage.input_tokens|num}/{usage.output_tokens|num}", - ["hello world!", "model=claude-opus-4-6 tokens=1.0k/50"], - "discord:channel:user", - undefined, - ], - [ - "keeps explicit responseUsage off during followup delivery", - "main", - "tokens", - "off", - undefined, - [], - undefined, - "hello world!", - ], - ] as const)( - "%s", - async ( - _name, - sessionKey, - configuredResponseUsage, - sessionResponseUsage, - usageTemplateText, - expectedFragments, - excludedText, - exactText, - ) => { - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - ...(sessionResponseUsage ? { responseUsage: sessionResponseUsage } : {}), - }; - const cfg = { - messages: { - responseUsage: configuredResponseUsage, - ...(usageTemplateText - ? { - usageTemplate: { - output: { default: [{ text: usageTemplateText }] }, - }, - } - : {}), - }, - } as OpenClawConfig; - - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 1_000, output: 50 }, - model: "claude-opus-4-6", - provider: "anthropic", - }, - }, - }, - runnerOverrides: { - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - }, - queued: createQueuedRun({ - run: { - config: cfg, - messageProvider: "discord", - sessionKey, - }, - }), - }); - - const payload = requireMockCallArg(onBlockReply, 0); - for (const fragment of expectedFragments) { - expect(payload.text).toContain(fragment); - } - if (excludedText) { - expect(payload.text).not.toContain(excludedText); - } - if (exactText) { - expect(payload.text).toBe(exactText); - } - }, - ); - - it("uses providerUsed for snapshot freshness when agent metadata overrides the run provider", async () => { - const storePath = "/tmp/openclaw-followup-usage-provider.json"; - const sessionKey = "main"; - const sessionEntry: SessionEntry = { sessionId: "session", updatedAt: Date.now() }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - registerFollowupTestSessionStore(storePath, sessionStore); - const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: { - agentMeta: { - usage: { input: 10, output: 5 }, - lastCallUsage: { input: 6, output: 3 }, - model: "claude-opus-4-6", - provider: "anthropic", - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: createAsyncReplySpy() }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - - await expect( - runner( - createQueuedRun({ - run: { - provider: "openai", - config: {} as OpenClawConfig, - }, - }), - ), - ).resolves.toBeUndefined(); - - expect(requireMockCallArg(persistSpy, 0).providerUsed).toBe("anthropic"); - expect(requireMockCallArg(persistSpy, 0).usageIsContextSnapshot).toBeUndefined(); - persistSpy.mockRestore(); - }); - - it("preserves user-facing session model state for queued internal announce fallback", async () => { - const storePath = "/tmp/openclaw-followup-internal-announce-usage.json"; - const sessionKey = "main"; - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - modelProvider: "openai", - model: "gpt-5.5", - contextTokens: 200_000, - inputTokens: 1_234, - outputTokens: 56, - cacheRead: 7, - cacheWrite: 8, - totalTokens: 1_305, - totalTokensFresh: true, - }; - const sessionStore: Record = { [sessionKey]: sessionEntry }; - registerFollowupTestSessionStore(storePath, sessionStore); - const persistSpy = vi.spyOn(sessionRunAccounting, "persistRunSessionUsage"); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "internal announce complete" }], - meta: { - agentMeta: { - usage: { input: 39_908, output: 122 }, - lastCallUsage: { input: 39_908, output: 122 }, - model: "gemini-2.5-flash", - provider: "google", - }, - }, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: createAsyncReplySpy() }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - sessionEntry, - sessionStore, - sessionKey, - storePath, - }); - - await expect( - runner( - createQueuedRun({ - run: { - inputProvenance: { - kind: "inter_session", - sourceSessionKey: "agent:codex:subagent:c34fca91", - sourceChannel: "__internal__", - sourceTool: "subagent_announce", - }, - }, - }), - ), - ).resolves.toBeUndefined(); - - const persistCall = requireMockCallArg(persistSpy, 0); - expect(persistCall.preserveUserFacingSessionModelState).toBe(true); - expect(sessionStore[sessionKey]?.modelProvider).toBe("openai"); - expect(sessionStore[sessionKey]?.model).toBe("gpt-5.5"); - expect(sessionStore[sessionKey]?.contextTokens).toBe(200_000); - expect(sessionStore[sessionKey]?.inputTokens).toBe(1_234); - expect(sessionStore[sessionKey]?.outputTokens).toBe(56); - expect(sessionStore[sessionKey]?.cacheRead).toBe(7); - expect(sessionStore[sessionKey]?.cacheWrite).toBe(8); - expect(sessionStore[sessionKey]?.totalTokens).toBe(1_305); - expect(sessionStore[sessionKey]?.totalTokensFresh).toBe(true); - persistSpy.mockRestore(); - }); - - it("does not send cross-channel payload content to dispatcher when origin routing fails", async () => { - routeReplyMock.mockResolvedValue({ - ok: false, - error: "forced route failure", - }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - expect(onBlockReply).toHaveBeenCalledTimes(1); - const reply = requireMockCallArg(onBlockReply, 0); - expect(reply.isError).toBe(true); - expect(String(reply.text)).toContain("could not deliver it to the originating channel"); - expectNoBlockReplyText(onBlockReply, "hello world!"); - expectNoBlockReplyText(onBlockReply, "second payload"); - }); - - it("suppresses cross-channel route-failure notices for room events", async () => { - routeReplyMock.mockResolvedValue({ - ok: false, - error: "forced route failure", - }); - const queued = baseQueuedRun("webchat"); - queued.currentInboundEventKind = "room_event"; - queued.originatingChannel = "discord"; - queued.originatingTo = "channel:C1"; - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] }, - queued, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it("does not emit cross-channel route-failure notice when a later payload routes", async () => { - routeReplyMock - .mockResolvedValueOnce({ - ok: false, - error: "transient route failure", - }) - .mockResolvedValueOnce({ ok: true }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }, { text: "second payload" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - expectNoBlockReplyTextIncludes(onBlockReply, "could not deliver it to the originating channel"); - }); - - it("leaves same-channel route-failure fallback hooks to downstream delivery", async () => { - routeReplyMock.mockResolvedValue({ - ok: false, - error: "forced route failure", - }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - }); - - it("uses dispatcher when origin routing metadata is incomplete", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: undefined, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - }); - - it("leaves dispatcher followup hooks to downstream delivery", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: undefined, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - }); - - it("does not run dispatcher followup hooks before downstream delivery", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: undefined, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(runReplyPayloadSendingHookMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - }); - - it("routes a visible fallback when an interactive followup completes empty", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingChatType: "direct", - originatingReplyToMode: "off", - } as FollowupRun, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - const routed = requireMockCallArg(routeReplyMock, 0); - expect(routed).toMatchObject({ - channel: "discord", - to: "channel:C1", - replyKind: "final", - payload: { - isError: true, - }, - }); - expect(String(requireRecord(routed.payload, "fallback payload").text)).toContain( - "did not produce a visible reply", - ); - expect(getReplyPayloadMetadataForTest(routed.payload as never)).toMatchObject({ - replyDelivery: { chatType: "direct", replyToMode: "off" }, - replyDeliverySource: { channel: "discord" }, - }); - }); - - it.each([ - [ - "reasoning", - { text: "internal reasoning", isReasoning: true }, - { reasoningPayloadsEnabled: true }, - ], - [ - "commentary", - { text: "internal commentary", isCommentary: true }, - { commentaryPayloadsEnabled: true }, - ], - ] satisfies Array<[string, Record, GetReplyOptions]>)( - "keeps enabled %s progress and appends the terminal fallback", - async (_label, progressPayload, opts) => { - await runMessagingCase({ - agentResult: { payloads: [progressPayload] }, - runnerOverrides: { opts }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - const routedPayloads = routeReplyMock.mock.calls.map((call) => - requireRecord(requireRecord(call[0], "route reply params").payload, "payload"), - ); - expect(routedPayloads).toContainEqual(expect.objectContaining(progressPayload)); - expect(routedPayloads).toContainEqual( - expect.objectContaining({ - text: expect.stringContaining("did not produce a visible reply"), - isError: true, - }), - ); - }, - ); - - it("routes the shared terminal failure for an empty failed followup", async () => { - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [], - meta: { error: { kind: "tool_result_mismatch", message: "private detail" } }, - }, - queued: { - ...queued, - currentInboundEventKind: "user_request", - originatingChannel: "discord", - originatingTo: "channel:C1", - }, - }); - - expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - isError: true, - }); - }); - - it("routes a terminal failure after only a message-tool progress delivery", async () => { - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [], - meta: { error: { kind: "tool_result_mismatch", message: "private detail" } }, - didDeliverSourceReplyViaMessageTool: true, - messagingToolSentTargets: [ - { - tool: "message", - provider: "discord", - to: "channel:C1", - sourceReplyFinal: false, - }, - ], - }, - queued: { - ...queued, - currentInboundEventKind: "user_request", - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - }, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - isError: true, - }); - }); - - it("routes a terminal failure when an empty result exhausts model fallback", async () => { - runWithModelFallbackMock.mockImplementationOnce( - async (params: { - provider: string; - model: string; - run: (provider: string, model: string) => Promise; - classifyResult: (attempt: { - result: unknown; - provider: string; - model: string; - attempt: number; - total: number; - }) => Promise> | Record; - }) => { - const result = await params.run(params.provider, params.model); - const classification = await params.classifyResult({ - result, - provider: params.provider, - model: params.model, - attempt: 1, - total: 1, - }); - expect(classification).toMatchObject({ - code: "empty_result", - preserveResultOnExhaustion: true, - preserveResultPriority: -1, - }); - return { - outcome: "exhausted", - result, - provider: params.provider, - model: params.model, - attempts: [{ reason: "format", code: "empty_result" }], - }; - }, - ); - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [], - meta: { agentHarnessResultClassification: "empty" }, - }, - queued: { - ...queued, - currentInboundEventKind: "user_request", - originatingChannel: "discord", - originatingTo: "channel:C1", - }, - }); - - expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - isError: true, - }); - }); - - it("routes a terminal failure when fallback throws without a preserved result", async () => { - const exhaustionError = new Error("All model fallback candidates failed"); - exhaustionError.name = "FallbackSummaryError"; - runWithModelFallbackMock.mockRejectedValueOnce(exhaustionError); - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...queued, - currentInboundEventKind: "user_request", - originatingChannel: "discord", - originatingTo: "channel:C1", - }, - }); - - expect(requireMockCallArg(routeReplyMock, 0).payload).toMatchObject({ - text: GENERIC_EXTERNAL_RUN_FAILURE_TEXT, - isError: true, - }); - }); - - it.each([ - [ - "NO_REPLY", - { payloads: [{ text: "NO_REPLY" }], meta: { finalAssistantVisibleText: "NO_REPLY" } }, - {}, - ], - ["a yielded continuation", { payloads: [], meta: { yielded: true } }, {}], - [ - "a pending tool continuation", - { payloads: [], meta: { pendingToolCalls: [{ name: "hosted_tool" }] } }, - {}, - ], - ["a room event", { payloads: [] }, { currentInboundEventKind: "room_event" }], - [ - "an internal handoff", - { payloads: [] }, - { run: { inputProvenance: { kind: "internal_system" } } }, - ], - ] satisfies Array< - [ - string, - Record, - { - currentInboundEventKind?: FollowupRun["currentInboundEventKind"]; - run?: Partial; - }, - ] - >)( - "keeps %s silent", - async ( - _label: string, - agentResult: Record, - queuedOverrides: { - currentInboundEventKind?: FollowupRun["currentInboundEventKind"]; - run?: Partial; - }, - ) => { - const queued = baseQueuedRun("discord"); - const runOverride = queuedOverrides.run; - const { onBlockReply } = await runMessagingCase({ - agentResult, - queued: { - ...queued, - ...queuedOverrides, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { ...queued.run, ...runOverride }, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }, - ); - - it("retains reply-lane ownership until empty fallback delivery settles", async () => { - let releaseDelivery = () => {}; - const deliveryStarted = new Promise((resolveStarted) => { - routeReplyMock.mockImplementationOnce( - async () => - await new Promise<{ ok: true }>((resolveDelivery) => { - releaseDelivery = () => resolveDelivery({ ok: true }); - resolveStarted(); - }), - ); + state.resolveDecision.mockImplementation(() => { + order.push("decision"); + return { kind: "deliver", payloads: [{ text: "done" } satisfies ReplyPayload] }; }); - runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - sessionKey: "main", - defaultModel: "openai/gpt-5.5", + state.deliver.mockImplementation(async () => { + order.push("delivered"); }); + state.completeLifecycle.mockImplementation(() => order.push("lifecycle-complete")); - const pending = runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { sessionKey: "main", sessionId: "active-session", messageProvider: "discord" }, - }), + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })( + turn.queued, ); - await deliveryStarted; - - expect(replyRunRegistryForTest.get("main")?.result).toMatchObject({ - kind: "failed", - code: "run_failed", - }); - expect(() => - createReplyOperationForTest({ - sessionKey: "main", - sessionId: "next-session", - resetTriggered: false, - }), - ).toThrow(); - - releaseDelivery(); - await pending; - expect(replyRunRegistryForTest.get("main")).toBeUndefined(); - }); - - it("routes the fallback for whitespace-only messaging evidence", async () => { - await runMessagingCase({ - agentResult: { - payloads: [], - messagingToolSentTexts: [" "], - messagingToolSentMediaUrls: ["\t"], - messagingToolSentTargets: [ - { tool: "message", provider: "discord", to: "channel:C1", text: " " }, - ], - }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - const routed = requireMockCallArg(routeReplyMock, 0); - expect(requireRecord(routed.payload, "fallback payload")).toMatchObject({ isError: true }); - }); - - it("routes the fallback for a whitespace-only assistant payload", async () => { - await runMessagingCase({ - agentResult: { payloads: [{ text: " \t\n " }] }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - const routed = requireMockCallArg(routeReplyMock, 0); - expect(requireRecord(routed.payload, "fallback payload")).toMatchObject({ isError: true }); - }); - it("routes the fallback for disabled commentary-only output", async () => { - await runMessagingCase({ - agentResult: { payloads: [{ text: "internal commentary", isCommentary: true }] }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - const routed = requireMockCallArg(routeReplyMock, 0); - expect(requireRecord(routed.payload, "fallback payload")).toMatchObject({ isError: true }); - }); - - it("routes the fallback after a hidden compaction retry", async () => { - await runMessagingCase({ - agentResult: { payloads: [] }, - agentEvent: { - stream: "compaction", - data: { phase: "end", completed: true, willRetry: true }, - }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - }); - - it.each([ - ["succeeds", { ok: true }], - ["fails", { ok: false, error: "forced route failure" }], - ["is hook-suppressed", { ok: true, suppressed: true }], - ])("routes the fallback after compaction progress that %s", async (_label, noticeResult) => { - routeReplyMock.mockResolvedValueOnce(noticeResult).mockResolvedValue({ ok: true }); - runEmbeddedAgentMock.mockImplementationOnce(async (runParams: unknown) => { - const onAgentEvent = requireRecord(runParams, "embedded run params").onAgentEvent; - if (typeof onAgentEvent !== "function") { - throw new Error("expected embedded run onAgentEvent callback"); - } - await onAgentEvent({ stream: "compaction", data: { phase: "start" } }); - return { payloads: [], meta: {} }; - }); - const queued = baseQueuedRun("discord"); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner({ - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - config: { agents: { defaults: { compaction: { notifyUser: true } } } }, - }, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - }); - - it("honors sendPolicy deny for queued origin delivery", async () => { - const onItemEvent = vi.fn(); - const staleSessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - sendPolicy: "allow", - }; - const persistedSessionEntry: SessionEntry = { - ...staleSessionEntry, - sendPolicy: "deny", - }; - const storePath = path.join(tmpdir(), "openclaw-followup-send-policy.json"); - registerFollowupTestSessionStore(storePath, { main: persistedSessionEntry }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "must stay private" }] }, - agentEvent: { - stream: "item", - data: { kind: "preamble", progressText: "also private" }, - }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { ...baseQueuedRun("discord").run, verboseLevel: "on" }, - } as FollowupRun, - runnerOverrides: { - sessionEntry: staleSessionEntry, - sessionKey: "main", - storePath, - opts: { onItemEvent }, - }, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - expect(onItemEvent).not.toHaveBeenCalled(); - }); - - it("keeps empty message-tool-only followup completions silent", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it.each([ - ["source delivery", { didDeliverSourceReplyViaMessageTool: true }], - ["source reply payload", { messagingToolSourceReplyPayloads: [{ text: "sent" }] }], - [ - "committed messaging target", - { messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:C1" }] }, - ], - [ - "accepted child-session spawn", - { acceptedSessionSpawns: [{ runId: "child-run", childSessionKey: "agent:main:child" }] }, - ], - ["cron side effect", { successfulCronAdds: 1 }], - ["deterministic approval prompt", { didSendDeterministicApprovalPrompt: true }], - ] satisfies Array<[string, Record]>)( - "keeps empty followup completions silent after %s", - async (_label, sideEffectEvidence) => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [], ...sideEffectEvidence }, - queued: { - ...baseQueuedRun("discord"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }, - ); - - it("keeps message-tool-only queued followup finals private", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - const runArg = requireMockCallArg(runEmbeddedAgentMock, 0); - expect(runArg.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect(runArg.forceMessageTool).toBe(true); - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it("enqueues a one-shot recovery retry for substantive message-tool-only queued followup finals", async () => { - const finalText = - "Here is the answer the queued user asked for. It includes enough detail to be a visible response, and it has another sentence so the substantive-final detector treats it as a real reply."; - const parentOnComplete = vi.fn(); - const parentLifecycle = { onAdopted: async () => {}, onSettled: parentOnComplete }; - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: finalText }], - meta: { finalAssistantVisibleText: finalText }, - }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - turnAdoptionLifecycle: parentLifecycle, - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - const retry = FOLLOWUP_TEST_QUEUES.get("main")?.items[0]; - expect(retry?.summaryLine).toBe("stranded-reply-retry"); - expect(retry?.strandedReplyRetry).toBe(true); - expect(retry?.disableCollectBatching).toBe(true); - expect(retry?.protectFromQueueOverflow).toBe(true); - expect(retry?.transcriptPrompt).toBeUndefined(); - expect(retry?.userTurnTranscriptRecorder).toBeUndefined(); - expect(retry?.currentInboundContext).toBeUndefined(); - expect(retry?.run.suppressNextUserMessagePersistence).toBe(true); - expect(retry?.run.sourceReplyDeliveryMode).toBe("message_tool_only"); - expect(retry?.prompt).toContain("message(action=send)"); - expect(retry?.prompt).toContain(finalText); - // System retry detaches from the client turn lifecycle; parent completion owns onComplete once. - expect(retry?.turnAdoptionLifecycle).toBeUndefined(); - expect(parentOnComplete).toHaveBeenCalledTimes(1); - }); - - it("excludes raw trace and status payloads from queued stranded recovery prompts", async () => { - const finalText = - "Here is the answer the queued user asked for. It includes enough detail to be a visible response, and it has another sentence so the substantive-final detector treats it as a real reply."; - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [ - { text: finalText }, - { - text: "🔎 Model Input (User Role):\n```text\nsecret queued trace that must not reach chat\n```", - }, - { text: "🧩 Active Memory: status=ok query=private-context", isStatusNotice: true }, - ], - meta: { finalAssistantVisibleText: finalText }, - }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - const retry = FOLLOWUP_TEST_QUEUES.get("main")?.items[0]; - expect(retry?.prompt).toContain(finalText); - expect(retry?.prompt).not.toContain("secret queued trace"); - expect(retry?.prompt).not.toContain("Active Memory"); - }); - - it("does not enqueue stranded recovery for message-tool-only queued room events", async () => { - const finalText = - "Here is a long ambient room-event note that must stay private. It has enough text and another sentence to otherwise look substantive."; - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [{ text: finalText }], - meta: { finalAssistantVisibleText: finalText }, - }, - queued: { - ...queued, - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(FOLLOWUP_TEST_QUEUES.get("main")?.items).toBeUndefined(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("does not route marked host media for message-tool-only queued room events", async () => { - const queued = baseQueuedRun("discord"); - await runMessagingCase({ - agentResult: { - payloads: [ - setReplyPayloadMetadataForTest( - { mediaUrl: "/tmp/generated.png" }, - { deliverDespiteSourceReplySuppression: true }, - ), - ], - }, - queued: { - ...queued, - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("does not enqueue stranded recovery when queued followup send policy denies delivery", async () => { - const finalText = - "Here is a long reply for a denied session. It includes enough detail to be substantive, but send-policy denial must remain an intentional delivery block."; - const queued = baseQueuedRun("discord"); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - sendPolicy: "deny", - }; - await runMessagingCase({ - agentResult: { - payloads: [{ text: finalText }], - meta: { finalAssistantVisibleText: finalText }, - }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - runnerOverrides: { sessionEntry, sessionKey: "main" }, - }); - - expect(FOLLOWUP_TEST_QUEUES.get("main")?.items).toBeUndefined(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("does not route marked host media when queued followup send policy denies delivery", async () => { - const queued = baseQueuedRun("discord"); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - sendPolicy: "deny", - }; - await runMessagingCase({ - agentResult: { - payloads: [ - setReplyPayloadMetadataForTest( - { mediaUrl: "/tmp/generated.png" }, - { deliverDespiteSourceReplySuppression: true }, - ), - ], - }, - queued: { - ...queued, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - runnerOverrides: { sessionEntry, sessionKey: "main" }, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("routes sanitized diagnostics when message-tool-only stranded retry strands again", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [{ text: "raw private final" }], - }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe( - "I generated a reply but could not deliver it to this chat. Please try again.", - ); - expect(String(routeReplyMock.mock.calls[0]?.[0]?.payload?.text)).not.toContain( - "raw private final", - ); + expect(order).toEqual([ + "progress-drained", + "accounted", + "decision", + "delivered", + "lifecycle-complete", + "operation-complete", + ]); + expect(state.clearRunContext).toHaveBeenCalledWith("run-1"); }); - it("routes sanitized diagnostics when message-tool-only stranded retry returns no payloads", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, + it("does not replay a settled turn when progress presentation fails", async () => { + const typing = createTypingController(); + const turn = createTurn(); + const execution = createRejectedExecution(); + execution.progress.drain = vi.fn(async () => { + throw new Error("presentation failed"); }); + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockResolvedValue(execution); + state.account.mockResolvedValue(undefined); + state.resolveDecision.mockReturnValue({ kind: "suppress", reason: "silent" }); + state.deliver.mockResolvedValue(undefined); - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe( - "I generated a reply but could not deliver it to this chat. Please try again.", + await createFollowupRunner({ typing, typingMode: "instant", defaultModel: "claude" })( + turn.queued, ); - }); - - it("does not route retry diagnostics when send policy denies delivery", async () => { - const queued = baseQueuedRun("discord"); - const sessionEntry: SessionEntry = { - sessionId: "session", - updatedAt: Date.now(), - sendPolicy: "deny", - }; - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - runnerOverrides: { sessionEntry, sessionKey: "main" }, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("does not treat the summary marker alone as a stranded retry", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [] }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); + expect(state.execute).toHaveBeenCalledOnce(); + expect(state.account).toHaveBeenCalledOnce(); + expect(state.deliver).toHaveBeenCalledOnce(); + expect(state.completeLifecycle).toHaveBeenCalledWith(turn.queued); + expect(turn.operation.fail).toHaveBeenCalledWith("run_failed", expect.any(Error)); }); - it("does not route retry diagnostics after message-tool delivery evidence", async () => { - const queued = baseQueuedRun("discord"); + it("reports a completed message-tool source delivery before final projection", async () => { + const typing = createTypingController(); const onObservedReplyDelivery = vi.fn(async () => {}); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [], - didDeliverSourceReplyViaMessageTool: true, - messagingToolSentTexts: ["visible recovered reply"], - messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:C1" }], - }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - runnerOverrides: { onObservedReplyDelivery }, - }); + const turn = createTurn(); + const execution = createRejectedExecution(); + execution.execution.outcome = { + kind: "settled", + status: "ok", + result: { payloads: [], meta: { durationMs: 0 } }, + resolved: { provider: "anthropic", model: "claude" }, + fallback: { exhausted: false, attempts: [] }, + autoCompactionCount: 0, + didLogHeartbeatStrip: false, + }; + state.completedSourceDelivery = true; + state.admit.mockResolvedValue({ kind: "admitted", turn }); + state.execute.mockResolvedValue(execution); + state.account.mockResolvedValue({}); + state.deliver.mockResolvedValue(undefined); - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onObservedReplyDelivery).toHaveBeenCalledTimes(1); - }); - - it("routes retry diagnostics when message-tool evidence contains only progress", async () => { - const queued = baseQueuedRun("discord"); - const onObservedReplyDelivery = vi.fn(async () => {}); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [], - didDeliverSourceReplyViaMessageTool: true, - messagingToolSentTexts: ["Still working…"], - messagingToolSentTargets: [ - { - tool: "message", - provider: "discord", - to: "channel:C1", - sourceReplyFinal: false, - }, - ], - }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - runnerOverrides: { onObservedReplyDelivery }, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe( - "I generated a reply but could not deliver it to this chat. Please try again.", - ); - expect(onObservedReplyDelivery).not.toHaveBeenCalled(); - }); - - it("routes retry diagnostics when message-tool sends to a non-source target", async () => { - const queued = baseQueuedRun("discord"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [], - didSendViaMessagingTool: true, - messagingToolSentTexts: ["sent somewhere else"], - messagingToolSentTargets: [{ tool: "message", provider: "discord", to: "channel:OTHER" }], - }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(routeReplyMock.mock.calls[0]?.[0]?.payload?.text).toBe( - "I generated a reply but could not deliver it to this chat. Please try again.", - ); - }); - - it("does not route retry diagnostics after internal source-reply payloads", async () => { - const queued = baseQueuedRun("webchat"); - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [], - messagingToolSourceReplyPayloads: [{ text: "visible recovered reply" }], - }, - queued: { - ...queued, - summaryLine: "stranded-reply-retry", - strandedReplyRetry: true, - originatingChannel: "webchat", - originatingTo: undefined, - run: { - ...queued.run, - sourceReplyDeliveryMode: "message_tool_only", - }, - } as FollowupRun, - }); - - expect(onBlockReply).not.toHaveBeenCalled(); - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("lets provider followup route hooks force dispatcher delivery", async () => { - resolveProviderFollowupFallbackRouteMock.mockReturnValue({ - route: "dispatcher", - reason: "operator-visible review copy", - }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - const routeArg = requireMockCallArg(resolveProviderFollowupFallbackRouteMock, 0); - expect(routeArg.provider).toBe("anthropic"); - const context = requireRecord(routeArg.context, "provider fallback context"); - expect(context.provider).toBe("anthropic"); - expect(context.modelId).toBe("claude"); - expect(context.originRoutable).toBe(true); - expect(context.dispatcherAvailable).toBe(true); - expect(requireRecord(context.payload, "provider fallback payload").text).toBe("hello world!"); - }); - - it("lets provider followup route hooks drop payloads explicitly", async () => { - resolveProviderFollowupFallbackRouteMock.mockReturnValue({ - route: "drop", - reason: "already delivered out of band", - }); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it("suppresses exact NO_REPLY followups without origin or dispatcher delivery", async () => { - const typing = createMockTypingController(); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: ` ${DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText} ` }], - meta: {}, - }); - const runner = createFollowupRunner({ + await createFollowupRunner({ typing, typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); + defaultModel: "claude", + opts: { onObservedReplyDelivery }, + })(turn.queued); - await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined })); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(typing.markRunComplete).toHaveBeenCalledTimes(1); - expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); - }); - - it("suppresses JSON NO_REPLY followups without origin or dispatcher delivery", async () => { - const typing = createMockTypingController(); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.jsonSilentText }], - meta: {}, - }); - const runner = createFollowupRunner({ - typing, - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(createQueuedRun({ originatingChannel: undefined, originatingTo: undefined })); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(typing.markRunComplete).toHaveBeenCalledTimes(1); - expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); - }); - - it("keeps NO_REPLY followups with media deliverable", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { - payloads: [ - { - text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText, - mediaUrl: "file:///tmp/followup.png", - }, - ], - }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: undefined, - originatingTo: undefined, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - const reply = requireMockCallArg(onBlockReply, 0); - expect(reply.text).toBe(DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText); - expect(reply.mediaUrl).toBe("file:///tmp/followup.png"); - }); - - it("falls back to dispatcher when successful output has no complete origin route", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.originChannel, - originatingTo: undefined, - } as FollowupRun, - }); - - expect(routeReplyMock).not.toHaveBeenCalled(); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, DELIVERY_NO_REPLY_RUNTIME_CONTRACT.dispatcherText); - }); - - it("falls back to dispatcher when same-channel origin routing fails", async () => { - routeReplyMock.mockResolvedValueOnce({ - ok: false, - error: "outbound adapter unavailable", - }); - const queued = baseQueuedRun(" Feishu "); - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...queued, - originatingChannel: "FEISHU", - originatingTo: "ou_abc123", - run: { - ...queued.run, - agentAccountId: undefined, - }, - } as FollowupRun, - }); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectBlockReplyText(onBlockReply, "hello world!"); - }); - - it("routes followups with originating account/thread metadata", async () => { - const { onBlockReply } = await runMessagingCase({ - agentResult: { payloads: [{ text: "hello world!" }] }, - queued: { - ...baseQueuedRun("webchat"), - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "work", - originatingThreadId: "1739142736.000100", - } as FollowupRun, - }); - - const routeArg = requireMockCallArg(routeReplyMock, 0); - expect(routeArg.channel).toBe("discord"); - expect(routeArg.to).toBe("channel:C1"); - expect(routeArg.accountId).toBe("work"); - expect(routeArg.threadId).toBe("1739142736.000100"); - expect(routeArg.replyKind).toBe("final"); - expect(routeArg.runId).toEqual(expect.any(String)); - expect(onBlockReply).not.toHaveBeenCalled(); - }); - - it("routes queued compaction notices through the durable origin path", async () => { - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { - onCompactionNotice?: (phase: "start" | "end") => Promise | void; - sessionEntry?: SessionEntry; - }) => { - await params.onCompactionNotice?.("start"); - await params.onCompactionNotice?.("end"); - return params.sessionEntry; - }, - ); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - const queued = createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingAccountId: "work", - originatingThreadId: "1739142736.000100", - messageId: "current-msg-1", - originatingReplyToId: "quoted-parent-1", - run: { - config: { - channels: { discord: { replyToMode: "all" } }, - agents: { defaults: { compaction: { notifyUser: true } } }, - }, - messageProvider: "discord", - }, - }); - - await runner(queued); - - expect(routeReplyMock).toHaveBeenCalledTimes(3); - const startRoute = requireMockCallArg(routeReplyMock, 0); - const endRoute = requireMockCallArg(routeReplyMock, 1); - const fallbackRoute = requireMockCallArg(routeReplyMock, 2); - expect(startRoute).toMatchObject({ - channel: "discord", - to: "channel:C1", - accountId: "work", - threadId: "1739142736.000100", - replyKind: "block", - mirror: false, - }); - expect(requireRecord(startRoute.payload, "start payload")).toMatchObject({ - text: "🧹 Compacting context...", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - expect(endRoute.replyKind).toBe("block"); - expect(endRoute.mirror).toBe(false); - expect(requireRecord(endRoute.payload, "end payload")).toMatchObject({ - text: "🧹 Compaction complete", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - expect(fallbackRoute.replyKind).toBe("final"); - expect(requireRecord(fallbackRoute.payload, "fallback payload")).toMatchObject({ - isError: true, - }); - }); - - it("suppresses queued compaction notices for room events", async () => { - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { - onCompactionNotice?: (phase: "start" | "end") => Promise | void; - sessionEntry?: SessionEntry; - }) => { - await params.onCompactionNotice?.("start"); - await params.onCompactionNotice?.("end"); - return params.sessionEntry; - }, - ); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - - await runner( - createQueuedRun({ - currentInboundEventKind: "room_event", - originatingChannel: "discord", - originatingTo: "channel:C1", - messageId: "current-msg-1", - run: { - config: { - channels: { discord: { replyToMode: "all" } }, - agents: { defaults: { compaction: { notifyUser: true } } }, - }, - messageProvider: "discord", - sourceReplyDeliveryMode: "message_tool_only", - }, - }), - ); - - expect(routeReplyMock).not.toHaveBeenCalled(); - }); - - it("routes queued compaction hook messages alongside notifyUser notices (#90185)", async () => { - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ - stream: "compaction", - data: { phase: "start", messages: ["Hook before"] }, - }); - await args.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", completed: true, messages: ["Hook after"] }, - }); - return { payloads: [], meta: {} }; - }, - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - - await runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - messageId: "current-msg-1", - run: { - config: { - channels: { discord: { replyToMode: "all" } }, - agents: { defaults: { compaction: { notifyUser: true } } }, - }, - messageProvider: "discord", - }, - }), - ); - - expect(routeReplyMock).toHaveBeenCalledTimes(5); - expect( - requireRecord(requireMockCallArg(routeReplyMock, 0).payload, "hook start"), - ).toMatchObject({ - text: "Hook before", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - expect( - requireRecord(requireMockCallArg(routeReplyMock, 1).payload, "notice start"), - ).toMatchObject({ - text: "🧹 Compacting context...", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - expect(requireRecord(requireMockCallArg(routeReplyMock, 2).payload, "hook end")).toMatchObject({ - text: "Hook after", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - expect( - requireRecord(requireMockCallArg(routeReplyMock, 3).payload, "notice end"), - ).toMatchObject({ - text: "🧹 Compaction complete", - replyToId: "current-msg-1", - replyToCurrent: true, - isCompactionNotice: true, - }); - const fallbackRoute = requireMockCallArg(routeReplyMock, 4); - expect(fallbackRoute.replyKind).toBe("final"); - expect(requireRecord(fallbackRoute.payload, "fallback payload")).toMatchObject({ - isError: true, - }); - }); - - it("applies reply-to mode filtering to queued compaction notices", async () => { - runPreflightCompactionIfNeededMock.mockImplementationOnce( - async (params: { - onCompactionNotice?: (phase: "start") => Promise | void; - sessionEntry?: SessionEntry; - }) => { - await params.onCompactionNotice?.("start"); - return params.sessionEntry; - }, - ); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [], - meta: {}, - }); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - - await runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingReplyToId: "reply-msg-1", - run: { - config: { - channels: { discord: { replyToMode: "off" } }, - agents: { defaults: { compaction: { notifyUser: true } } }, - }, - messageProvider: "discord", - }, - }), - ); - - expect(routeReplyMock).toHaveBeenCalledTimes(2); - const payload = requireRecord(requireMockCallArg(routeReplyMock, 0).payload, "notice payload"); - expect(payload).toMatchObject({ - text: "🧹 Compacting context...", - isCompactionNotice: true, - }); - expect(payload.replyToId).toBeUndefined(); - const fallbackRoute = requireMockCallArg(routeReplyMock, 1); - expect(fallbackRoute.replyKind).toBe("final"); - const fallbackPayload = requireRecord(fallbackRoute.payload, "fallback payload"); - expect(fallbackPayload).toMatchObject({ isError: true }); - expect(fallbackPayload.replyToId).toBeUndefined(); - }); - - it("plans queued compaction notices with the active fallback candidate", async () => { - runWithModelFallbackMock.mockImplementationOnce( - async (params: { - run: (provider: string, model: string) => Promise<{ payloads: unknown[]; meta: object }>; - }) => ({ - result: await params.run("google", "gemini-2.5-flash"), - provider: "google", - model: "gemini-2.5-flash", - }), - ); - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ stream: "compaction", data: { phase: "start" } }); - return { - payloads: [{ text: DELIVERY_NO_REPLY_RUNTIME_CONTRACT.silentText }], - meta: {}, - }; - }, - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - - await runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - run: { - config: { agents: { defaults: { compaction: { notifyUser: true } } } }, - provider: "anthropic", - model: "claude", - messageProvider: "discord", - }, - }), - ); - - const routeArg = requireMockCallArg(resolveProviderFollowupFallbackRouteMock, 0); - expect(routeArg.provider).toBe("google"); - const context = requireRecord(routeArg.context, "provider fallback context"); - expect(context.provider).toBe("google"); - expect(context.modelId).toBe("gemini-2.5-flash"); - expect(requireRecord(context.payload, "provider fallback payload")).toMatchObject({ - text: "🧹 Compacting context...", - isCompactionNotice: true, - }); - }); - - it("suppresses queued compaction completion notices while compaction will retry", async () => { - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAgentEvent?: (evt: { stream: string; data: Record }) => Promise; - }) => { - await args.onAgentEvent?.({ - stream: "compaction", - data: { - phase: "end", - completed: true, - willRetry: true, - messages: ["compaction hook says done"], - }, - }); - return { payloads: [], meta: {} }; - }, - ); - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "openai/gpt-5.5", - }); - - await runner( - createQueuedRun({ - originatingChannel: "discord", - originatingTo: "channel:C1", - originatingReplyToId: "reply-msg-1", - run: { - config: { - channels: { discord: { replyToMode: "all" } }, - agents: { defaults: { compaction: { notifyUser: true } } }, - }, - messageProvider: "discord", - }, - }), - ); - - expect(routeReplyMock).toHaveBeenCalledTimes(1); - const routed = requireMockCallArg(routeReplyMock, 0); - expect(routed.replyKind).toBe("final"); - const payload = requireRecord(routed.payload, "fallback payload"); - expect(payload).toMatchObject({ isError: true }); - expect(payload.isCompactionNotice).not.toBe(true); - expect(String(payload.text)).toContain("did not produce a visible reply"); + expect(onObservedReplyDelivery).toHaveBeenCalledOnce(); }); }); - -describe("createFollowupRunner typing cleanup", () => { - async function runTypingCase(agentResult: Record) { - const typing = createMockTypingController(); - runEmbeddedAgentMock.mockResolvedValueOnce({ - meta: {}, - ...agentResult, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply: createAsyncReplySpy() }, - typing, - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(baseQueuedRun()); - return typing; - } - - function expectTypingCleanup(typing: ReturnType) { - expect(typing.markRunComplete).toHaveBeenCalledTimes(1); - expect(typing.markDispatchIdle).toHaveBeenCalledTimes(1); - } - - it("calls both markRunComplete and markDispatchIdle on NO_REPLY", async () => { - const typing = await runTypingCase({ payloads: [{ text: "NO_REPLY" }] }); - expectTypingCleanup(typing); - }); - - it("calls both markRunComplete and markDispatchIdle on empty payloads", async () => { - const typing = await runTypingCase({ payloads: [] }); - expectTypingCleanup(typing); - }); - - it("calls both markRunComplete and markDispatchIdle on agent error", async () => { - const typing = createMockTypingController(); - runEmbeddedAgentMock.mockRejectedValueOnce(new Error("agent exploded")); - - const runner = createFollowupRunner({ - opts: { onBlockReply: vi.fn(async () => {}) }, - typing, - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(baseQueuedRun()); - - expectTypingCleanup(typing); - }); - - it("calls both markRunComplete and markDispatchIdle on successful delivery", async () => { - const typing = createMockTypingController(); - const onBlockReply = vi.fn(async () => {}); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - meta: {}, - }); - - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing, - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - - await runner(baseQueuedRun()); - - expect(onBlockReply).toHaveBeenCalledTimes(1); - expectTypingCleanup(typing); - }); -}); - -describe("createFollowupRunner agentDir forwarding", () => { - it("passes queued run agentDir to runEmbeddedAgent", async () => { - runEmbeddedAgentMock.mockClear(); - const onBlockReply = vi.fn(async () => {}); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "hello world!" }], - messagingToolSentTexts: ["different message"], - meta: {}, - }); - const runner = createFollowupRunner({ - opts: { onBlockReply }, - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-6", - }); - const agentDir = path.join("/tmp", "agent-dir"); - const queued = createQueuedRun(); - await runner({ - ...queued, - run: { - ...queued.run, - agentDir, - }, - }); - - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(1); - const call = requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent"); - expect(call.agentDir).toBe(agentDir); - }); -}); - -describe("createFollowupRunner queued user message idempotency across fallback", () => { - it("suppresses queued user message persistence after first fallback candidate persists it", async () => { - runEmbeddedAgentMock.mockClear(); - runWithModelFallbackMock.mockReset(); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream 500"); - return { - result: await params.run("openai", "gpt-5.4"), - provider: "openai", - model: "gpt-5.4", - }; - }, - ); - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onUserMessagePersisted?: (message: { - role: "user"; - content: Array<{ type: "text"; text: string }>; - }) => void; - }) => { - args.onUserMessagePersisted?.({ - role: "user", - content: [{ type: "text", text: "queued message" }], - }); - throw new Error("upstream 500"); - }, - ); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - run: { - provider: "anthropic", - model: "claude-opus-4-7", - suppressNextUserMessagePersistence: false, - }, - }), - ); - - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); - const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); - expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false); - expect(secondAttempt.suppressNextUserMessagePersistence).toBe(true); - }); - - it("only persists assistant error stub on the first fallback candidate", async () => { - runEmbeddedAgentMock.mockClear(); - runWithModelFallbackMock.mockReset(); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream 500"); - await expect(params.run("anthropic", "claude-opus-4-6")).rejects.toThrow("upstream 500"); - return { - result: await params.run("openai", "gpt-5.4"), - provider: "openai", - model: "gpt-5.4", - }; - }, - ); - runEmbeddedAgentMock.mockImplementationOnce( - async (args: { - onAssistantErrorMessagePersisted?: (message: { - role: "assistant"; - content: string; - stopReason: "error"; - }) => void; - }) => { - args.onAssistantErrorMessagePersisted?.({ - role: "assistant", - content: "[assistant turn failed before producing content]", - stopReason: "error", - }); - throw new Error("upstream 500"); - }, - ); - runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream 500")); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - run: { - provider: "anthropic", - model: "claude-opus-4-7", - }, - }), - ); - - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(3); - const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); - const thirdAttempt = requireMockCallArg(runEmbeddedAgentMock, 2); - expect(firstAttempt.suppressAssistantErrorPersistence).toBe(false); - expect(secondAttempt.suppressAssistantErrorPersistence).toBe(true); - expect(thirdAttempt.suppressAssistantErrorPersistence).toBe(true); - }); - - it("does not suppress when no fallback candidate persisted the queued message", async () => { - runEmbeddedAgentMock.mockClear(); - runWithModelFallbackMock.mockReset(); - runWithModelFallbackMock.mockImplementationOnce( - async (params: { run: (provider: string, model: string) => Promise }) => { - await expect(params.run("anthropic", "claude-opus-4-7")).rejects.toThrow("upstream early"); - return { - result: await params.run("openai", "gpt-5.4"), - provider: "openai", - model: "gpt-5.4", - }; - }, - ); - runEmbeddedAgentMock.mockRejectedValueOnce(new Error("upstream early")); - runEmbeddedAgentMock.mockResolvedValueOnce({ - payloads: [{ text: "ok" }], - meta: {}, - }); - - const runner = createFollowupRunner({ - typing: createMockTypingController(), - typingMode: "instant", - defaultModel: "anthropic/claude-opus-4-7", - }); - - await runner( - createQueuedRun({ - run: { - provider: "anthropic", - model: "claude-opus-4-7", - suppressNextUserMessagePersistence: false, - }, - }), - ); - - expect(runEmbeddedAgentMock).toHaveBeenCalledTimes(2); - const firstAttempt = requireMockCallArg(runEmbeddedAgentMock, 0); - const secondAttempt = requireMockCallArg(runEmbeddedAgentMock, 1); - expect(firstAttempt.suppressNextUserMessagePersistence).toBe(false); - expect(secondAttempt.suppressNextUserMessagePersistence).toBe(false); - expect(secondAttempt.suppressAssistantErrorPersistence).toBe(false); - }); -}); -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts index 37f0d66ac06a..ed0a7a0b092f 100644 --- a/src/auto-reply/reply/followup-runner.ts +++ b/src/auto-reply/reply/followup-runner.ts @@ -1,2058 +1,185 @@ -/** Runs queued follow-up agent turns and routes their delivery payloads. */ -import crypto from "node:crypto"; -import { readStringValue } from "@openclaw/normalization-core/string-coerce"; -import { hasOutboundReplyContent } from "openclaw/plugin-sdk/reply-payload"; -import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js"; -import { - clearAutoFallbackPrimaryProbeSelection, - entryMatchesAutoFallbackPrimaryProbe, - markAutoFallbackPrimaryProbe, -} from "../../agents/agent-scope.js"; -import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js"; -import { getCliSessionBinding } from "../../agents/cli-session.js"; -import { resolveContextTokensForModel } from "../../agents/context.js"; -import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js"; -import { - hasCompletedSourceReplyDeliveryEvidence, - hasCompletedTerminalDeliveryEvidence, - hasCommittedSourceReplyDeliveryEvidence, - hasVisibleOutboundDeliveryEvidence, -} from "../../agents/embedded-agent-runner/delivery-evidence.js"; -import { hasDeliberateSilentTerminalReply } from "../../agents/embedded-agent-runner/result-fallback-classifier.js"; -import { runEmbeddedAgentEntry } from "../../agents/embedded-agent-runner/run-entry.js"; -import { runEmbeddedAgent } from "../../agents/embedded-agent.js"; -import type { FastModeAutoProgressState } from "../../agents/fast-mode.js"; -import { isFallbackSummaryError } from "../../agents/model-fallback.js"; -import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js"; -import { isCliProvider } from "../../agents/model-selection-cli.js"; -import { - isAgentRunRestartAbortReason, - resolveAgentRunErrorLifecycleFields, -} from "../../agents/run-termination.js"; -import { buildAgentRuntimeDeliveryPlan } from "../../agents/runtime-plan/build.js"; -import { withLocalSessionPlacementTurnAdmission } from "../../agents/session-placement-admission.js"; -import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js"; -import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js"; -import { normalizeAgentPlanSteps } from "../../channels/streaming.js"; -import type { SessionEntry } from "../../config/sessions.js"; -import { - loadSessionEntry, - loadSessionEntryReadOnly, - updateSessionEntry, -} from "../../config/sessions/session-accessor.js"; -import type { TypingMode } from "../../config/types.js"; -import { logVerbose } from "../../globals.js"; -import { - captureAgentRunLifecycleGeneration, - clearAgentRunContext, - emitAgentEvent, - getAgentEventLifecycleGeneration, - registerAgentRunContext, -} from "../../infra/agent-events.js"; +/** Composes queued admission, canonical execution, accounting, and delivery. */ +import { hasCompletedSourceReplyDeliveryEvidence } from "../../agents/embedded-agent-runner/delivery-evidence.js"; +import { clearAgentRunContext } from "../../infra/agent-events.js"; import { formatErrorMessage } from "../../infra/errors.js"; import { defaultRuntime } from "../../runtime.js"; -import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js"; -import { resolveSendPolicy } from "../../sessions/send-policy.js"; -import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; -import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import { accountFollowupTurn } from "./agent-runner-result-accounting.js"; +import { deliverFollowupDecision, resolveFollowupDeliveryDecision } from "./followup-delivery.js"; import { - getReplyPayloadMetadata, - isReplyPayloadStatusNotice, - markReplyPayloadForSourceSuppressionDelivery, -} from "../reply-payload.js"; -import type { GetReplyOptions, ReplyPayload } from "../types.js"; + admitFollowupTurn, + type AdmittedFollowupTurn, + type FollowupRunnerParams, +} from "./followup-turn-admission.js"; +import { executeFollowupTurn } from "./followup-turn-execution.js"; import { - createAgentLifecycleTerminalBackstop, - type AgentLifecycleTerminalBackstop, -} from "./agent-lifecycle-terminal.js"; -import { resolveRunAfterAutoFallbackPrimaryProbeRecheck } from "./agent-runner-auto-fallback.js"; -import { - clearDroppedCliSessionBinding, - createCliReasoningStreamBridge, - createCliToolSummaryTracker, - keepCliSessionBindingOnlyWhenReused, - runCliAgentWithLifecycle, -} from "./agent-runner-cli-dispatch.js"; -import { buildCommandOutputFromToolResultEvent } from "./agent-runner-command-output.js"; -import { - buildEmptyInteractiveReplyPayload, - buildPreflightCompactionFailureText, - buildTerminalAgentRunFailureReplyPayload, -} from "./agent-runner-failure-reply.js"; -import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; -import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js"; -import { - resolveQueuedReplyExecutionConfig, - resolveQueuedReplyRuntimeConfig, - resolveModelFallbackOptions, - resolveRunFastModeForFallbackCandidate, - resolveRunAuthProfile, -} from "./agent-runner-utils.js"; -import { - createCompactionHookNoticePayload, - createCompactionNoticePayload, - readCompactionHookMessages, - shouldNotifyUserAboutCompaction, - type CompactionNoticePhase, -} from "./compaction-notice.js"; -import { resolveFollowupDeliveryPayloads } from "./followup-delivery.js"; -import { type InternalGetReplyOptions, shouldBridgeCliPreambleEvents } from "./get-reply.types.js"; -import { refreshActiveGoalContext } from "./inbound-meta.js"; -import { resolveOriginMessageProvider } from "./origin-routing.js"; -import { sanitizePendingFinalDeliveryText } from "./pending-final-delivery.js"; -import { - shouldWarnAboutPrivateMessageToolFinal, - warnPrivateMessageToolFinal, -} from "./private-message-tool-final.js"; -import { - admitFollowupRunLifecycle, completeFollowupRunLifecycle, - enqueueFollowupRun, FollowupRunDeferredError, - isFollowupRunAborted, - refreshQueuedFollowupSession, - resolveFollowupAbortSignal, type FollowupRun, - resolveQueueSettings, } from "./queue.js"; -import { normalizeReplyPayloadDirectives } from "./reply-delivery.js"; -import type { ReplyDispatchKind } from "./reply-dispatcher.types.js"; import type { ReplyOperation } from "./reply-run-registry.js"; -import { admitReplyTurn } from "./reply-turn-admission.js"; -import { buildReplyUsageState } from "./reply-usage-state.js"; -import { isRoutableChannel, routeReply } from "./route-reply.js"; -import { incrementRunCompactionCount, persistRunSessionUsage } from "./session-run-accounting.js"; -import { resolveSourceReplyVisibilityPolicy } from "./source-reply-delivery-mode.js"; -import { - buildStrandedReplyDeliveryFailurePayload, - buildStrandedReplyRetryFollowupRun, -} from "./stranded-reply-recovery.js"; -import { createTypingSignaler } from "./typing-mode.js"; -import type { TypingController } from "./typing.js"; -type EmbeddedAgentRunResult = Awaited>; - -type FollowupAgentEvent = { stream: string; data: Record }; - -function isStrandedReplyRetryFollowup(queued: FollowupRun): boolean { - return ( - queued.strandedReplyRetry === true && - queued.currentInboundEventKind !== "room_event" && - queued.run.sourceReplyDeliveryMode === "message_tool_only" - ); -} - -function hasSuccessfulFollowupSourceReplyDelivery(params: { - didDeliverSourceReplyViaMessageTool?: boolean; - messagingToolSentTargets?: EmbeddedAgentRunResult["messagingToolSentTargets"]; - messagingToolSourceReplyPayloads?: EmbeddedAgentRunResult["messagingToolSourceReplyPayloads"]; -}): boolean { - return hasCompletedSourceReplyDeliveryEvidence(params); -} - -function normalizeAssistantFinalDeliveryText(text: string): string { - const parsed = normalizeReplyPayloadDirectives({ - payload: { text }, - trimLeadingWhitespace: true, - parseMode: "auto", - }); - return sanitizePendingFinalDeliveryText(parsed.payload.text ?? ""); -} - -function readApprovalScopeValue(value: unknown): "turn" | "session" | undefined { - return value === "turn" || value === "session" ? value : undefined; -} - -function filterStringArray(value: unknown): string[] | undefined { - return Array.isArray(value) - ? value.filter((entry): entry is string => typeof entry === "string") - : undefined; -} - -function hasFailedFollowupProgressEvent(evt: FollowupAgentEvent): boolean { - const commandOutput = buildCommandOutputFromToolResultEvent(evt); - if (commandOutput) { - return ( - commandOutput.status === "failed" || - commandOutput.status === "error" || - (typeof commandOutput.exitCode === "number" && commandOutput.exitCode !== 0) - ); - } - if (evt.stream !== "item" && evt.stream !== "command_output") { - return false; - } - const phase = readStringValue(evt.data.phase); - const status = readStringValue(evt.data.status); - return ( - phase === "error" || - status === "failed" || - status === "error" || - (typeof evt.data.exitCode === "number" && evt.data.exitCode !== 0) - ); -} - -async function forwardFollowupProgressEvent(params: { - evt: FollowupAgentEvent; - opts?: GetReplyOptions; - detailMode?: "explain" | "raw"; - emitChannelProgress?: boolean; - onCompactionComplete?: () => void; - notifyUserAboutCompaction?: boolean; - currentMessageId?: string; - onCompactionNoticePayload?: (payload: ReplyPayload) => Promise | void; -}): Promise { - const { evt, opts } = params; - let visible = false; - const emitChannelProgress = params.emitChannelProgress !== false; - const allowQuietToolLifecycle = - evt.stream === "tool" && opts?.allowToolLifecycleWhenProgressHidden === true; - if (!emitChannelProgress && evt.stream !== "compaction" && !allowQuietToolLifecycle) { - return false; - } - - if (evt.stream === "tool" && evt.data.hideFromChannelProgress !== true) { - const phase = readStringValue(evt.data.phase) ?? ""; - const name = readStringValue(evt.data.name); - if (phase === "start" || phase === "update") { - await opts?.onToolStart?.({ - itemId: readStringValue(evt.data.itemId), - toolCallId: readStringValue(evt.data.toolCallId), - name, - phase, - args: - evt.data.args && typeof evt.data.args === "object" - ? (evt.data.args as Record) - : undefined, - detailMode: params.detailMode, - }); - } - const commandOutput = buildCommandOutputFromToolResultEvent(evt); - if (commandOutput && opts?.onCommandOutput) { - visible = (await opts.onCommandOutput(commandOutput)) !== false; - } - } - - const suppressItemChannelProgress = - evt.stream === "item" && - evt.data.suppressChannelProgress === true && - Boolean(opts?.onToolStart); - const hideItemFromChannelProgress = - evt.stream === "item" && evt.data.hideFromChannelProgress === true; - if (evt.stream === "item" && !suppressItemChannelProgress && !hideItemFromChannelProgress) { - if (opts?.onItemEvent) { - visible = - (await opts.onItemEvent({ - itemId: readStringValue(evt.data.itemId), - toolCallId: readStringValue(evt.data.toolCallId), - kind: readStringValue(evt.data.kind), - title: readStringValue(evt.data.title), - name: readStringValue(evt.data.name), - phase: readStringValue(evt.data.phase), - status: readStringValue(evt.data.status), - summary: readStringValue(evt.data.summary), - progressText: readStringValue(evt.data.progressText), - meta: readStringValue(evt.data.meta), - approvalId: readStringValue(evt.data.approvalId), - approvalSlug: readStringValue(evt.data.approvalSlug), - })) !== false; - } - } - - if (evt.stream === "plan") { - await opts?.onPlanUpdate?.({ - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - explanation: readStringValue(evt.data.explanation), - steps: normalizeAgentPlanSteps(evt.data.steps), - source: readStringValue(evt.data.source), - }); - } - - if (evt.stream === "approval") { - await opts?.onApprovalEvent?.({ - phase: readStringValue(evt.data.phase), - kind: readStringValue(evt.data.kind), - status: readStringValue(evt.data.status), - title: readStringValue(evt.data.title), - itemId: readStringValue(evt.data.itemId), - toolCallId: readStringValue(evt.data.toolCallId), - approvalId: readStringValue(evt.data.approvalId), - approvalSlug: readStringValue(evt.data.approvalSlug), - command: readStringValue(evt.data.command), - host: readStringValue(evt.data.host), - reason: readStringValue(evt.data.reason), - scope: readApprovalScopeValue(evt.data.scope), - message: readStringValue(evt.data.message), - }); - } - - if (evt.stream === "command_output" && opts?.onCommandOutput) { - visible = - (await opts.onCommandOutput({ - itemId: readStringValue(evt.data.itemId), - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - toolCallId: readStringValue(evt.data.toolCallId), - name: readStringValue(evt.data.name), - output: readStringValue(evt.data.output), - status: readStringValue(evt.data.status), - exitCode: - typeof evt.data.exitCode === "number" || evt.data.exitCode === null - ? evt.data.exitCode - : undefined, - durationMs: typeof evt.data.durationMs === "number" ? evt.data.durationMs : undefined, - cwd: readStringValue(evt.data.cwd), - })) !== false; - } - - if (evt.stream === "patch") { - await opts?.onPatchSummary?.({ - itemId: readStringValue(evt.data.itemId), - phase: readStringValue(evt.data.phase), - title: readStringValue(evt.data.title), - toolCallId: readStringValue(evt.data.toolCallId), - name: readStringValue(evt.data.name), - added: filterStringArray(evt.data.added), - modified: filterStringArray(evt.data.modified), - deleted: filterStringArray(evt.data.deleted), - summary: readStringValue(evt.data.summary), - }); - } - - if (evt.stream === "compaction") { - const phase = readStringValue(evt.data.phase) ?? ""; - const hookMessages = readCompactionHookMessages(evt.data.messages); - const sendCompactionUserNotices = async (noticePhase: "start" | "end" | "incomplete") => { - const hookPayload = createCompactionHookNoticePayload({ - messages: hookMessages, - currentMessageId: params.currentMessageId, - }); - if (hookPayload) { - await params.onCompactionNoticePayload?.(hookPayload); - } - if (params.notifyUserAboutCompaction === true) { - await params.onCompactionNoticePayload?.( - createCompactionNoticePayload({ - phase: noticePhase, - currentMessageId: params.currentMessageId, - }), - ); - } - }; - if (phase === "start" && emitChannelProgress) { - await opts?.onCompactionStart?.(); - } - if (phase === "start") { - await sendCompactionUserNotices("start"); - } - if (phase === "end" && evt.data?.completed === true) { - params.onCompactionComplete?.(); - if (emitChannelProgress) { - await opts?.onCompactionEnd?.(); - } - if (evt.data?.willRetry === true) { - return visible; - } - await sendCompactionUserNotices("end"); - } else if (phase === "end") { - await sendCompactionUserNotices("incomplete"); - } - } - return visible; -} +type FollowupDrainDisposition = + | { kind: "consumed" } + | { kind: "deferred"; reason: string } + | { kind: "retry"; error: unknown }; /** Creates the function that drains one queued follow-up run. */ -export function createFollowupRunner(params: { - opts?: InternalGetReplyOptions; - typing: TypingController; - typingMode: TypingMode; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; - defaultModel: string; - agentCfgContextTokens?: number; - toolProgressDetail?: "explain" | "raw"; -}): (queued: FollowupRun) => Promise { - const { - opts, - typing, - typingMode, - sessionEntry, - sessionStore, - sessionKey, - storePath, - defaultModel, - agentCfgContextTokens, - toolProgressDetail, - } = params; - const typingSignals = createTypingSignaler({ - typing, - mode: typingMode, - isHeartbeat: opts?.isHeartbeat === true, - }); - - /** - * Sends followup payloads, routing to the originating channel if set. - * - * When originatingChannel/originatingTo are set on the queued run, - * replies are routed directly to that provider instead of using the - * session's current dispatcher. This ensures replies go back to - * where the message originated. - */ - const sendFollowupPayloads = async ( - payloads: ReplyPayload[], - queued: FollowupRun, - resolvedRun: { provider: string; modelId: string }, - options: { kind?: ReplyDispatchKind; mirror?: boolean; runId?: string } = {}, - ): Promise => { - // Check if we should route to originating channel. - const { originatingChannel, originatingTo } = queued; - const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config); - const shouldRouteToOriginating = isRoutableChannel(originatingChannel) && originatingTo; - const deliveryPlan = buildAgentRuntimeDeliveryPlan({ - provider: resolvedRun.provider, - modelId: resolvedRun.modelId, - config: runtimeConfig, - workspaceDir: queued.run.workspaceDir, - agentDir: queued.run.agentDir, - }); - - const sendablePayloads = payloads.filter( - (payload): payload is ReplyPayload => - hasOutboundReplyContent(payload) && - (!deliveryPlan.isSilentPayload(payload) || - getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true), - ); - - if (sendablePayloads.length === 0) { - return false; - } - - if (!shouldRouteToOriginating && !opts?.onBlockReply) { - defaultRuntime.error?.( - "followup queue: completed with payloads but no origin route or visible dispatcher is available", - ); - return false; - } - - let deliveredAnyPayload = false; - let crossChannelRouteFailureNeedsNotice = false; - let routedAnyCrossChannelPayloadToOrigin = false; - const replyKind = options.kind ?? "final"; - const sendDispatcherPayload = async (payload: ReplyPayload): Promise => { - if (!opts?.onBlockReply) { - return false; - } - if (deliveryPlan.isSilentPayload(payload)) { - return false; - } - await opts.onBlockReply(payload); - return true; - }; - for (const payload of sendablePayloads) { - const providerRoute = deliveryPlan.resolveFollowupRoute({ - payload, - originatingChannel, - originatingTo, - originRoutable: Boolean(shouldRouteToOriginating), - dispatcherAvailable: Boolean(opts?.onBlockReply), - }); - if (providerRoute?.route === "drop") { - logVerbose( - `followup queue: provider hook dropped payload route reason=${providerRoute.reason ?? "unspecified"}`, - ); - continue; - } - const deliveryRoute = - providerRoute?.route === "origin" && shouldRouteToOriginating - ? "origin" - : providerRoute?.route === "dispatcher" && opts?.onBlockReply - ? "dispatcher" - : shouldRouteToOriginating - ? "origin" - : opts?.onBlockReply - ? "dispatcher" - : undefined; - await typingSignals.signalTextDelta(payload.text); - - // Route to originating channel if set, otherwise fall back to dispatcher. - if (deliveryRoute === "origin" && isRoutableChannel(originatingChannel) && originatingTo) { - const payloadMetadata = getReplyPayloadMetadata(payload); - const hasTranscriptOwner = - payloadMetadata?.assistantMessageIndex !== undefined || - payloadMetadata?.assistantTranscriptOwned === true; - const result = await routeReply({ - payload, - channel: originatingChannel, - to: originatingTo, - sessionKey: queued.run.sessionKey, - accountId: queued.originatingAccountId, - requesterSenderId: queued.run.senderId, - requesterSenderName: queued.run.senderName, - requesterSenderUsername: queued.run.senderUsername, - requesterSenderE164: queued.run.senderE164, - threadId: queued.originatingThreadId, - cfg: runtimeConfig, - mirror: hasTranscriptOwner ? false : options.mirror, - replyKind, - runId: options.runId, - }); - if (!result.ok) { - const errorMsg = result.error ?? "unknown error"; - logVerbose(`followup queue: route-reply failed: ${errorMsg}`); - const provider = resolveOriginMessageProvider({ - provider: queued.run.messageProvider, - }); - const origin = resolveOriginMessageProvider({ - originatingChannel, - }); - if (opts?.onBlockReply) { - if (origin && origin === provider) { - deliveredAnyPayload = (await sendDispatcherPayload(payload)) || deliveredAnyPayload; - } else { - crossChannelRouteFailureNeedsNotice = true; - } - } else { - defaultRuntime.error?.(`followup queue: route-reply failed: ${errorMsg}`); - } - } else if (!result.suppressed) { - deliveredAnyPayload = true; - const provider = resolveOriginMessageProvider({ - provider: queued.run.messageProvider, - }); - const origin = resolveOriginMessageProvider({ - originatingChannel, - }); - if (origin && provider && origin !== provider) { - routedAnyCrossChannelPayloadToOrigin = true; - } - } - } else if (deliveryRoute === "dispatcher") { - deliveredAnyPayload = (await sendDispatcherPayload(payload)) || deliveredAnyPayload; - } - } - if ( - crossChannelRouteFailureNeedsNotice && - !routedAnyCrossChannelPayloadToOrigin && - opts?.onBlockReply - ) { - if (queued.currentInboundEventKind === "room_event") { - logVerbose("followup queue: cross-channel failure notice suppressed for room_event"); - return deliveredAnyPayload; - } - deliveredAnyPayload = - (await sendDispatcherPayload({ - text: - "Follow-up completed, but OpenClaw could not deliver it to the originating " + - "channel. The reply content was not forwarded to this channel to avoid " + - "cross-channel misdelivery.", - isError: true, - })) || deliveredAnyPayload; - } - return deliveredAnyPayload; - }; - - const runFollowupTurn = async (queued: FollowupRun) => { - if (isFollowupRunAborted(queued)) { - completeFollowupRunLifecycle(queued); - typing.markRunComplete(); - typing.markDispatchIdle(); - return; - } - const endDeliveryCorrelations = (queued.deliveryCorrelations ?? []) - .map((correlation) => correlation.begin()) - .filter((end): end is () => void => typeof end === "function"); - const queuedImages = queued.images ?? opts?.images; - const queuedImageOrder = queued.imageOrder ?? opts?.imageOrder; - const queuedMedia = queued.media ?? opts?.media; - let replyOperation: ReplyOperation | undefined; - let deferred = false; - let failed = false; - +export function createFollowupRunner( + defaults: FollowupRunnerParams, +): (queued: FollowupRun) => Promise { + const runFollowup = async (queued: FollowupRun): Promise => { + let disposition: FollowupDrainDisposition = { kind: "retry", error: undefined }; + let operation: ReplyOperation | undefined; + let admittedRunId: string | undefined; + let executionStarted = false; + const initiallyAborted = + queued.abortSignal?.aborted === true || queued.queueAbortSignal?.aborted === true; + const endDeliveryCorrelations = initiallyAborted + ? [] + : (queued.deliveryCorrelations ?? []) + .map((correlation) => correlation.begin()) + .filter((end): end is () => void => typeof end === "function"); try { - queued.run.config = await resolveQueuedReplyExecutionConfig(queued.run.config, { - originatingChannel: queued.originatingChannel, - messageProvider: queued.run.messageProvider, - originatingAccountId: queued.originatingAccountId, - agentAccountId: queued.run.agentAccountId, - }); - const replySessionKey = queued.run.sessionKey ?? sessionKey; - const runtimeConfig = resolveQueuedReplyRuntimeConfig(queued.run.config); - let effectiveQueued = - runtimeConfig === queued.run.config - ? queued - : { ...queued, run: { ...queued.run, config: runtimeConfig } }; - let run = effectiveQueued.run; - let activeSessionEntry = - (replySessionKey ? sessionStore?.[replySessionKey] : undefined) ?? - (replySessionKey === sessionKey ? sessionEntry : undefined); - run = resolveRunAfterAutoFallbackPrimaryProbeRecheck({ - run, - entry: activeSessionEntry, - sessionKey: replySessionKey, - }); - if (run !== effectiveQueued.run) { - effectiveQueued = { ...effectiveQueued, run }; - } - const resolveCurrentVerboseLevel = () => { - if (replySessionKey && storePath) { - try { - const level = loadSessionEntryReadOnly({ - storePath, - sessionKey: replySessionKey, - })?.verboseLevel; - if (typeof level === "string" && level.trim()) { - return level; - } - } catch { - // Keep queued delivery resilient to transient session-store reads. - } - } - const liveEntryLevel = replySessionKey - ? sessionStore?.[replySessionKey]?.verboseLevel - : undefined; - return liveEntryLevel ?? activeSessionEntry?.verboseLevel ?? run.verboseLevel; - }; - const shouldEmitVerboseProgress = () => { - const verboseLevel = resolveCurrentVerboseLevel(); - return verboseLevel === "on" || verboseLevel === "full"; - }; - const shouldSuppressDefaultToolProgressMessages = () => !shouldEmitVerboseProgress(); - const shouldEmitToolResultProgress = () => - shouldEmitVerboseProgress() && !shouldSuppressDefaultToolProgressMessages(); - const shouldEmitToolOutputProgress = () => - resolveCurrentVerboseLevel() === "full" && !shouldSuppressDefaultToolProgressMessages(); - const isRoomEventFollowup = () => queued.currentInboundEventKind === "room_event"; - let observedVisibleToolErrorProgress = false; - const markVisibleToolErrorProgress = () => { - if (resolveCurrentVerboseLevel() === "on" && shouldEmitToolResultProgress()) { - observedVisibleToolErrorProgress = true; - } - }; - const shouldSuppressToolErrorWarnings = () => { - if (opts?.suppressToolErrorWarnings !== undefined) { - return opts.suppressToolErrorWarnings; - } - if (!shouldEmitVerboseProgress()) { - return false; - } - return observedVisibleToolErrorProgress ? true : undefined; - }; - let progressDeliveryChain: Promise = Promise.resolve(); - const pendingProgressDeliveries = new Set>(); - const enqueueProgressDelivery = (deliver: () => Promise) => { - progressDeliveryChain = progressDeliveryChain.then(deliver).catch((err: unknown) => { - logVerbose(`followup queue: progress delivery failed: ${formatErrorMessage(err)}`); - }); - const task = progressDeliveryChain.finally(() => { - pendingProgressDeliveries.delete(task); - }); - pendingProgressDeliveries.add(task); - return task; - }; - const drainProgressDeliveries = async () => { - while (pendingProgressDeliveries.size > 0) { - await Promise.all(pendingProgressDeliveries); - } - }; - const admission = await admitReplyTurn({ - sessionId: effectiveQueued.admissionSessionId ?? run.sessionId, - sessionKey: replySessionKey ?? "", - expectedSessionId: activeSessionEntry?.sessionId, - storePath, - kind: "queued_followup", - resetTriggered: false, - routeThreadId: queued.originatingThreadId, - upstreamAbortSignal: resolveFollowupAbortSignal(queued), - onReplyAdmissionWaitChange: effectiveQueued.onReplyAdmissionWaitChange, - }); - if (admission.status === "skipped") { - if (admission.reason === "active-run") { - deferred = true; - throw new FollowupRunDeferredError("Follow-up reply lane is still active"); - } + if (initiallyAborted) { + disposition = { kind: "consumed" }; return; } - replyOperation = admission.operation; - // Failure paths may still drain progress or route a recovery payload. Keep lane ownership - // until finally completes so the next turn cannot overtake that asynchronous delivery. - replyOperation.retainFailureUntilComplete(); - // Multi-source collected turns become atomic at reply-lane admission. - // Their queue owner uses this boundary to retire source cancellation ids. - await admitFollowupRunLifecycle(effectiveQueued); - // Admission can await transport-owned durability. Supersession during that handoff is - // sticky; stop before preflight can emit notices or start provider work for the stale turn. - if (isFollowupRunAborted(effectiveQueued)) { - return; - } - // Channel delivery state belongs to one admitted run. Give the active - // dispatcher a boundary before callbacks from this followup can reuse it. - await opts?.onQueuedFollowupAdmitted?.(); - if (replyOperation.sessionId !== run.sessionId) { - run = { ...run, sessionId: replyOperation.sessionId }; - effectiveQueued = { ...effectiveQueued, run }; - } - // Admission may wait while session policy changes. Reload persisted state before any - // delivery decision; the enqueue-time in-memory snapshot is not authoritative here. - const admittedSessionEntry = replySessionKey - ? storePath - ? loadSessionEntry({ storePath, sessionKey: replySessionKey }) - : sessionStore?.[replySessionKey] - : undefined; - if (admittedSessionEntry?.sessionId === replyOperation.sessionId) { - activeSessionEntry = admittedSessionEntry; - // Admission is the authority for policy on this exact session generation. A queued - // snapshot may predate catalog adoption, but a replacement session must not inherit it. - run = { - ...run, - ...(admittedSessionEntry.sessionFile - ? { sessionFile: admittedSessionEntry.sessionFile } - : {}), - modelSelectionLocked: admittedSessionEntry.modelSelectionLocked === true, - }; - effectiveQueued = { ...effectiveQueued, run }; - } - const sendPolicyDenied = - resolveSendPolicy({ - cfg: runtimeConfig, - entry: activeSessionEntry, - sessionKey: run.runtimePolicySessionKey ?? replySessionKey, - channel: queued.originatingChannel ?? run.messageProvider, - chatType: run.chatType ?? activeSessionEntry?.chatType, - }) === "deny"; - const progressOpts = sendPolicyDenied ? undefined : opts; - const preserveProgressCallbackStartOrder = - progressOpts?.preserveProgressCallbackStartOrder === true; - // Carry the admission-time policy through every queued delivery path; direct origin routing - // bypasses the outer dispatcher that normally enforces sendPolicy. - const sendRunPayloads: typeof sendFollowupPayloads = async (...args) => { - if (sendPolicyDenied) { - return false; - } - return sendFollowupPayloads(...args); - }; - // Admission already loads the latest entry under the lifecycle fence. - const goalContextSessionEntry = admission.sessionEntry ?? activeSessionEntry; - const currentInboundContext = - opts?.isHeartbeat === true - ? effectiveQueued.currentInboundContext - : refreshActiveGoalContext( - effectiveQueued.currentInboundContext, - goalContextSessionEntry, - ); - const runId = crypto.randomUUID(); - const shouldSurfaceToControlUi = isInternalMessageChannel( - resolveOriginMessageProvider({ - originatingChannel: queued.originatingChannel, - provider: run.messageProvider, - }), - ); - let autoCompactionCount = 0; - let runResult: Awaited>; - let fallbackProvider = run.provider; - let fallbackModel = run.model; - let fallbackExhausted = false; - let terminalRunFailed = false; - const resolveFollowupCurrentMessageId = () => - run.inputProvenance?.kind === "internal_system" && - run.inputProvenance.sourceTool === "restart-sentinel" - ? queued.originatingReplyToId - : queued.messageId; - const compactionNoticeReplyToId = resolveFollowupCurrentMessageId(); - const sendCompactionNoticePayload = async ( - payload: ReplyPayload, - resolvedRun: { provider: string; modelId: string } = { - provider: fallbackProvider, - modelId: fallbackModel, + const admission = await admitFollowupTurn({ + queued, + defaults, + onCompactionNoticePayload: async (payload, turn) => { + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [payload] }, + turn, + defaults, + runId: turn.runId, + runFollowup, + kind: "block", + }); }, - ) => { - if (isRoomEventFollowup()) { - logVerbose("followup queue: compaction notice suppressed for room_event"); - return; - } - const noticePayloads = resolveFollowupDeliveryPayloads({ - cfg: runtimeConfig, - payloads: [payload], - messageProvider: run.messageProvider, - originatingAccountId: queued.originatingAccountId ?? run.agentAccountId, - originatingChannel: queued.originatingChannel, - originatingChatType: queued.originatingChatType, - originatingReplyToMode: queued.originatingReplyToMode, - originatingTo: queued.originatingTo, - reasoningPayloadsEnabled: opts?.reasoningPayloadsEnabled === true, - commentaryPayloadsEnabled: opts?.commentaryPayloadsEnabled === true, - }); - if (noticePayloads.length === 0) { - return; - } - await sendRunPayloads(noticePayloads, effectiveQueued, resolvedRun, { - kind: "block", - mirror: false, - runId, - }); - }; - const notifyPreflightCompaction = shouldNotifyUserAboutCompaction(runtimeConfig) - ? async (phase: CompactionNoticePhase) => { - await sendCompactionNoticePayload( - createCompactionNoticePayload({ - phase, - currentMessageId: compactionNoticeReplyToId, - }), - ); - } - : undefined; - let lifecycleGeneration = captureAgentRunLifecycleGeneration(runId); - if (run.sessionKey) { - registerAgentRunContext(runId, { - sessionKey: run.sessionKey, - ...(run.sessionId ? { sessionId: run.sessionId } : {}), - agentId: run.agentId, - lifecycleGeneration, - verboseLevel: run.verboseLevel, - isControlUiVisible: shouldSurfaceToControlUi, - }); - } - const prePreflightCompactionCount = activeSessionEntry?.compactionCount ?? 0; - let preflightCompactionApplied; - try { - activeSessionEntry = await runPreflightCompactionIfNeeded({ - cfg: runtimeConfig, - followupRun: effectiveQueued, - promptForEstimate: queued.prompt, - defaultModel, - agentCfgContextTokens, - sessionEntry: activeSessionEntry, - sessionStore, - sessionKey: replySessionKey, - storePath, - isHeartbeat: opts?.isHeartbeat === true, - replyOperation, - onCompactionNotice: notifyPreflightCompaction, - }); - preflightCompactionApplied = - (activeSessionEntry?.compactionCount ?? 0) > prePreflightCompactionCount; - } catch (err) { - clearAgentRunContext(runId, lifecycleGeneration); - const message = formatErrorMessage(err); - replyOperation.fail("run_failed", err); - const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, { - includeDetails: run.verboseLevel === "on" || run.verboseLevel === "full", - }); - if (preflightCompactionFailureText) { - if (isRoomEventFollowup()) { - logVerbose( - "followup queue: preflight compaction failure notice suppressed for room_event", - ); - return; - } - await sendRunPayloads( - [ - markReplyPayloadForSourceSuppressionDelivery({ - text: preflightCompactionFailureText, - }), - ], - effectiveQueued, - { provider: fallbackProvider, modelId: fallbackModel }, - ); - return; - } - throw err; - } - if (run.sessionKey) { - const owningSessionId = - activeSessionEntry?.sessionId === run.sessionId - ? activeSessionEntry.sessionId - : run.sessionId; - registerAgentRunContext(runId, { - sessionKey: run.sessionKey, - ...(owningSessionId ? { sessionId: owningSessionId } : {}), - agentId: run.agentId, - lifecycleGeneration, - verboseLevel: run.verboseLevel, - isControlUiVisible: shouldSurfaceToControlUi, - }); - } - let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( - activeSessionEntry?.systemPromptReport, - ); - const preserveUserFacingSessionState = shouldPreserveUserFacingSessionStateForInputProvenance( - queued.run.inputProvenance, - ); - const resolveRunForFallbackCandidate = ( - provider: string, - model: string, - ): FollowupRun["run"] => { - const probe = run.autoFallbackPrimaryProbe; - const isPrimaryProbeCandidate = - probe && provider === probe.provider && model === probe.model; - if ( - probe && - provider === probe.fallbackProvider && - !isPrimaryProbeCandidate && - probe.fallbackAuthProfileId - ) { - const candidateRun: FollowupRun["run"] = { - ...run, - provider, - model, - authProfileId: probe.fallbackAuthProfileId, - }; - if (probe.fallbackAuthProfileIdSource) { - candidateRun.authProfileIdSource = probe.fallbackAuthProfileIdSource; - } else { - delete candidateRun.authProfileIdSource; - } - return candidateRun; - } - return run; - }; - const clearRecoveredAutoFallbackPrimaryProbe = async (paramsForClear: { - provider: string; - model: string; - }): Promise => { - if (preserveUserFacingSessionState) { - return; - } - const probe = run.autoFallbackPrimaryProbe; - if (!probe) { - return; - } - if (paramsForClear.provider !== probe.provider || paramsForClear.model !== probe.model) { - return; - } - if (!replySessionKey || !sessionStore) { - return; - } - const entry = sessionStore[replySessionKey] ?? activeSessionEntry; - if (!entry || !entryMatchesAutoFallbackPrimaryProbe(entry, probe)) { - return; - } - clearAutoFallbackPrimaryProbeSelection(entry); - sessionStore[replySessionKey] = entry; - activeSessionEntry = entry; - if (!storePath) { - return; - } - await updateSessionEntry({ storePath, sessionKey: replySessionKey }, (persistedEntry) => { - if (!entryMatchesAutoFallbackPrimaryProbe(persistedEntry, probe)) { - return null; - } - const shouldClearAuthProfile = - persistedEntry.authProfileOverrideSource === "auto" || - (persistedEntry.authProfileOverrideSource === undefined && - persistedEntry.authProfileOverrideCompactionCount !== undefined); - clearAutoFallbackPrimaryProbeSelection(persistedEntry); - return { - providerOverride: undefined, - modelOverride: undefined, - modelOverrideSource: undefined, - modelOverrideRouteResolution: undefined, - modelOverrideFallbackOriginProvider: undefined, - modelOverrideFallbackOriginModel: undefined, - ...(shouldClearAuthProfile - ? { - authProfileOverride: undefined, - authProfileOverrideSource: undefined, - authProfileOverrideCompactionCount: undefined, - } - : {}), - fallbackNoticeSelectedModel: undefined, - fallbackNoticeActiveModel: undefined, - fallbackNoticeReason: undefined, - updatedAt: persistedEntry.updatedAt, - }; - }); - }; - fallbackProvider = run.provider; - fallbackModel = run.model; - replyOperation.setPhase("running"); - const runAbortSignal = replyOperation.abortSignal; - let pendingLifecycleTerminal: - | { - provider: string; - model: string; - backstop: AgentLifecycleTerminalBackstop; - } - | undefined; - let queuedUserMessagePersistedAcrossFallback = false; - let assistantErrorPersistedAcrossFallback = false; - const fastModeStartedAtMs = Date.now(); - const fastModeAutoProgressState: FastModeAutoProgressState = { - offAnnounced: false, - resetAnnounced: false, - }; - try { - const selection = resolveModelFallbackOptions(run, runtimeConfig); - const fallbackResult = await runEmbeddedAgentEntry({ - selection: { - cfg: selection.cfg, - provider: selection.provider, - model: selection.model, - requestedRouteResolution: selection.requestedRouteResolution, - agentDir: selection.agentDir, - fallbacksOverride: selection.fallbacksOverride, - }, - identity: { - runId, - agentId: run.agentId, - sessionId: run.sessionId, - sessionKey: selection.sessionKey, - }, - harness: { - workspaceDir: run.workspaceDir, - sessionKey: run.runtimePolicySessionKey ?? replySessionKey, - preparation: { kind: "direct" }, - resolveRuntimeOverride: (provider) => - resolveSessionRuntimeOverrideForProvider({ - provider, - entry: activeSessionEntry, - cfg: runtimeConfig, - }), - }, - behavior: { kind: "followup-delivery" }, - sessionOverride: { - kind: "reconcile-completed", - reconcile: clearRecoveredAutoFallbackPrimaryProbe, - }, - abortSignal: runAbortSignal, - runCandidate: async (provider, model, runOptions) => { - const suppressQueuedUserPersistenceForCandidate = - (run.suppressNextUserMessagePersistence ?? false) || - queuedUserMessagePersistedAcrossFallback; - const suppressAssistantErrorPersistenceForCandidate = - assistantErrorPersistedAcrossFallback; - const candidateRun = resolveRunForFallbackCandidate(provider, model); - const candidateThinkLevel = resolveCandidateThinkingLevel({ - cfg: runtimeConfig, - provider, - modelId: model, - level: run.thinkLevel, - agentId: run.agentId, - sessionKey: run.runtimePolicySessionKey ?? replySessionKey, - sessionEntry: activeSessionEntry, - }); - const candidateFastMode = resolveRunFastModeForFallbackCandidate({ - run: candidateRun, - config: runtimeConfig, - provider, - model, - sessionEntry: activeSessionEntry, - }); - const activeProbe = run.autoFallbackPrimaryProbe; - if (activeProbe && provider === activeProbe.provider && model === activeProbe.model) { - markAutoFallbackPrimaryProbe({ - probe: activeProbe, - sessionKey: replySessionKey, - }); - } - const selectedAuthProfile = resolveRunAuthProfile(candidateRun, provider, { - config: runtimeConfig, - }); - const sessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({ - provider, - entry: activeSessionEntry, - cfg: runtimeConfig, - }); - // A locked harness owns the transcript. A configured CLI backend with the - // same id must not steal dispatch from that persisted harness. - const locksPersistedHarness = - activeSessionEntry?.modelSelectionLocked === true && - normalizeOptionalAgentRuntimeId(activeSessionEntry.agentHarnessId) === - sessionRuntimeOverride; - const pinnedCliRuntime = - !locksPersistedHarness && - sessionRuntimeOverride && - isCliProvider(sessionRuntimeOverride, runtimeConfig) - ? sessionRuntimeOverride - : undefined; - const cliExecutionProvider = - pinnedCliRuntime ?? - (sessionRuntimeOverride - ? provider - : (resolveCliRuntimeExecutionProvider({ - provider, - cfg: runtimeConfig, - agentId: run.agentId, - modelId: model, - authProfileId: selectedAuthProfile.authProfileId, - }) ?? provider)); - const useCliExecution = - pinnedCliRuntime !== undefined || - (!sessionRuntimeOverride && isCliProvider(cliExecutionProvider, runtimeConfig)); - let attemptCompactionCount = 0; - const userTurnTranscriptRecorder = - effectiveQueued.userTurnTranscriptRecorder ?? opts?.userTurnTranscriptRecorder; - const notifyUserMessagePersisted = () => { - queuedUserMessagePersistedAcrossFallback = true; - }; - // Shared by the embedded onToolResult callback and the CLI tool - // summary tracker so both runners deliver identical durable summaries. - const deliverFollowupToolSummary = (payload: ReplyPayload) => - enqueueProgressDelivery(async () => { - // room_event turns are ambient; only an explicit message tool call - // may post back into the source chat. - if (isRoomEventFollowup()) { - return; - } - if ( - run.sourceReplyDeliveryMode === "message_tool_only" && - !shouldEmitToolResultProgress() - ) { - return; - } - await sendRunPayloads( - [payload], - effectiveQueued, - { - provider, - modelId: model, - }, - { kind: "tool", mirror: false, runId }, - ); - if (payload.isError === true) { - markVisibleToolErrorProgress(); - } - }); - try { - if (useCliExecution) { - const cliSessionBinding = getCliSessionBinding( - activeSessionEntry, - cliExecutionProvider, - ); - const cliLifecycleStartedAt = Date.now(); - const lifecycleBackstop = createAgentLifecycleTerminalBackstop({ - runId, - sessionKey: replySessionKey, - startedAt: cliLifecycleStartedAt, - getLifecycleGeneration: () => lifecycleGeneration, - resolveTerminationFields: (error) => - resolveAgentRunErrorLifecycleFields(error, runAbortSignal), - }); - let droppedCliSessionReplacement = false; - pendingLifecycleTerminal = { provider, model, backstop: lifecycleBackstop }; - const followupCurrentMessageId = resolveFollowupCurrentMessageId(); - const cliToolSummaryTracker = createCliToolSummaryTracker({ - detailMode: toolProgressDetail, - shouldEmitToolResult: shouldEmitToolResultProgress, - shouldEmitToolOutput: shouldEmitToolOutputProgress, - deliver: deliverFollowupToolSummary, - }); - const result = await withLocalSessionPlacementTurnAdmission( - { - sessionId: run.sessionId, - sessionKey: replySessionKey, - agentId: run.agentId, - runId, - }, - () => - runCliAgentWithLifecycle({ - runId, - lifecycleGeneration, - provider: cliExecutionProvider, - startedAt: cliLifecycleStartedAt, - emitLifecycleTerminal: false, - onAgentRunStart: () => opts?.onAgentRunStart?.(runId), - suppressAssistantBridge: run.silentExpected, - onActivity: () => replyOperation?.recordActivity(), - preserveProgressCallbackStartOrder, - onReasoningText: createCliReasoningStreamBridge( - progressOpts?.onReasoningStream, - ), - onPlanUpdate: progressOpts?.onPlanUpdate, - onReasoningProgress: async (payload) => { - await progressOpts?.onReasoningProgress?.(payload); - }, - onToolEvent: async (payload) => { - if (!preserveProgressCallbackStartOrder) { - await cliToolSummaryTracker.noteToolEvent(payload); - if (payload.phase === "result") { - return; - } - await forwardFollowupProgressEvent({ - evt: { - stream: "tool", - data: { - name: payload.name, - phase: payload.phase, - args: payload.args, - }, - }, - opts: progressOpts, - detailMode: toolProgressDetail, - emitChannelProgress: shouldEmitToolResultProgress(), - }); - return; - } - if (payload.phase === "result") { - await cliToolSummaryTracker.noteToolEvent(payload); - return; - } - // CLI bridges drain independently. Start channel presentation before - // summary bookkeeping can yield and let later progress overtake this tool. - const presentationPromise = forwardFollowupProgressEvent({ - evt: { - stream: "tool", - data: { name: payload.name, phase: payload.phase, args: payload.args }, - }, - opts: progressOpts, - detailMode: toolProgressDetail, - emitChannelProgress: shouldEmitToolResultProgress(), - }); - await Promise.all([ - presentationPromise, - cliToolSummaryTracker.noteToolEvent(payload), - ]); - }, - onCommentaryText: - progressOpts?.onItemEvent && shouldBridgeCliPreambleEvents(progressOpts) - ? async ({ text, itemId }) => { - await forwardFollowupProgressEvent({ - evt: { - stream: "item", - data: { kind: "preamble", progressText: text, itemId }, - }, - opts: progressOpts, - detailMode: toolProgressDetail, - }); - } - : undefined, - onFastModeAutoProgress: async (payload) => { - await enqueueProgressDelivery(async () => { - // Mirrors direct dispatch progress suppression: ambient - // room events never get automatic fast-mode notices. - if (isRoomEventFollowup()) { - return; - } - await sendRunPayloads( - [payload], - effectiveQueued, - { - provider, - modelId: model, - }, - { kind: "tool", mirror: false, runId }, - ); - }); - }, - transformResult: - queued.currentInboundEventKind === "room_event" - ? (resultLocal) => - keepCliSessionBindingOnlyWhenReused({ - result: resultLocal, - existingSessionId: cliSessionBinding?.sessionId, - onDroppedReplacement: () => { - droppedCliSessionReplacement = true; - }, - }) - : undefined, - runParams: { - replyOperation, - sessionId: run.sessionId, - sessionKey: replySessionKey, - runtimePolicySessionKey: run.runtimePolicySessionKey, - agentId: run.agentId, - trigger: opts?.isHeartbeat === true ? "heartbeat" : "user", - sessionFile: run.sessionFile, - workspaceDir: run.workspaceDir, - cwd: run.cwd, - config: runtimeConfig, - prompt: queued.prompt, - transcriptPrompt: queued.transcriptPrompt, - suppressNextUserMessagePersistence: - suppressQueuedUserPersistenceForCandidate, - userTurnTranscriptRecorder, - onUserMessagePersisted: notifyUserMessagePersisted, - persistAssistantTranscript: - queued.currentInboundEventKind !== "room_event" && - run.suppressTranscriptOnlyAssistantPersistence !== true, - storePath, - currentInboundEventKind: queued.currentInboundEventKind, - currentInboundAudio: queued.currentInboundAudio, - currentInboundContext, - inputProvenance: run.inputProvenance, - modelProvider: provider, - provider: cliExecutionProvider, - execOverrides: run.execOverrides, - bashElevated: run.bashElevated, - model, - ...resolveRunAuthProfile(candidateRun, cliExecutionProvider, { - config: runtimeConfig, - }), - thinkLevel: candidateThinkLevel, - fastMode: candidateFastMode.fastMode, - fastModeStartedAtMs, - fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds, - fastModeAutoProgressState, - isFinalFallbackAttempt: runOptions?.isFinalFallbackAttempt, - timeoutMs: run.timeoutMs, - runTimeoutOverrideMs: run.runTimeoutOverrideMs, - runId, - extraSystemPrompt: run.extraSystemPrompt, - sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, - taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, - silentReplyPromptMode: run.silentReplyPromptMode, - allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, - extraSystemPromptStatic: run.extraSystemPromptStatic, - cliSessionBindingFacts: run.cliSessionBindingFacts, - ownerNumbers: run.ownerNumbers, - cliSessionId: cliSessionBinding?.sessionId, - cliSessionBinding, - bootstrapPromptWarningSignaturesSeen, - bootstrapPromptWarningSignature: - bootstrapPromptWarningSignaturesSeen[ - bootstrapPromptWarningSignaturesSeen.length - 1 - ], - images: queuedImages, - imageOrder: queuedImageOrder, - media: queuedMedia, - skillsSnapshot: run.skillsSnapshot, - messageChannel: queued.originatingChannel ?? undefined, - messageProvider: resolveOriginMessageProvider({ - originatingChannel: queued.originatingChannel, - provider: run.messageProvider, - }), - clientCaps: run.clientCaps, - currentChannelId: queued.originatingTo, - senderId: run.senderId, - senderName: run.senderName, - senderUsername: run.senderUsername, - senderE164: run.senderE164, - groupId: run.groupId, - groupChannel: run.groupChannel, - groupSpace: run.groupSpace, - spawnedBy: run.spawnedBy, - chatId: queued.originatingChatId, - channelContext: run.channelContext, - currentThreadTs: - queued.originatingThreadId != null - ? String(queued.originatingThreadId) - : undefined, - currentMessageId: followupCurrentMessageId, - agentAccountId: run.agentAccountId, - senderIsOwner: run.senderIsOwner, - disableTools: opts?.disableTools, - abortSignal: runAbortSignal, - }, - }), - ); - if (droppedCliSessionReplacement) { - await clearDroppedCliSessionBinding({ - provider: cliExecutionProvider, - sessionKey: replySessionKey, - sessionStore, - storePath, - activeSessionEntry, - }); - } - bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( - result.meta?.systemPromptReport, - ); - return result; - } - const lifecycleBackstop = createAgentLifecycleTerminalBackstop({ - runId, - sessionKey: replySessionKey, - getLifecycleGeneration: () => lifecycleGeneration, - resolveTerminationFields: (error) => - resolveAgentRunErrorLifecycleFields(error, runAbortSignal), - }); - pendingLifecycleTerminal = { provider, model, backstop: lifecycleBackstop }; - const followupCurrentMessageId = resolveFollowupCurrentMessageId(); - const runSessionTarget = - storePath && run.sessionKey - ? { - ...(run.agentId ? { agentId: run.agentId } : {}), - ...(run.sessionId ? { sessionId: run.sessionId } : {}), - sessionKey: run.sessionKey, - storePath, - } - : undefined; - const result = await runEmbeddedAgent({ - allowGatewaySubagentBinding: true, - lifecycleGeneration, - replyOperation, - sessionId: run.sessionId, - sessionKey: run.sessionKey, - agentId: run.agentId, - sessionTarget: runSessionTarget, - trigger: "user", - messageChannel: queued.originatingChannel ?? undefined, - messageProvider: run.messageProvider, - // Queued turns must keep the originating client's declared caps or - // capability-gated tools vanish between the live turn and its drain. - clientCaps: run.clientCaps, - chatType: run.chatType, - agentAccountId: run.agentAccountId, - messageTo: queued.originatingTo, - messageThreadId: queued.originatingThreadId, - currentChannelId: queued.originatingTo, - chatId: queued.originatingChatId, - currentThreadTs: - queued.originatingThreadId != null - ? String(queued.originatingThreadId) - : undefined, - currentMessageId: followupCurrentMessageId, - groupId: run.groupId, - groupChannel: run.groupChannel, - groupSpace: run.groupSpace, - senderId: run.senderId, - senderName: run.senderName, - senderUsername: run.senderUsername, - senderE164: run.senderE164, - channelContext: run.channelContext, - sessionFile: run.sessionFile, - agentDir: run.agentDir, - workspaceDir: run.workspaceDir, - cwd: run.cwd, - config: runtimeConfig, - skillsSnapshot: run.skillsSnapshot, - prompt: queued.prompt, - transcriptPrompt: queued.transcriptPrompt, - userTurnTranscriptRecorder, - currentInboundEventKind: queued.currentInboundEventKind, - currentInboundAudio: queued.currentInboundAudio, - currentInboundContext, - extraSystemPrompt: run.extraSystemPrompt, - silentReplyPromptMode: run.silentReplyPromptMode, - sourceReplyDeliveryMode: run.sourceReplyDeliveryMode, - taskSuggestionDeliveryMode: run.taskSuggestionDeliveryMode, - forceMessageTool: run.sourceReplyDeliveryMode === "message_tool_only", - suppressNextUserMessagePersistence: suppressQueuedUserPersistenceForCandidate, - onUserMessagePersisted: notifyUserMessagePersisted, - suppressTranscriptOnlyAssistantPersistence: - run.suppressTranscriptOnlyAssistantPersistence, - suppressAssistantErrorPersistence: suppressAssistantErrorPersistenceForCandidate, - onAssistantErrorMessagePersisted: () => { - assistantErrorPersistedAcrossFallback = true; - }, - ownerNumbers: run.ownerNumbers, - enforceFinalTag: run.enforceFinalTag, - allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, - provider, - model, - modelSelectionLocked: run.modelSelectionLocked, - agentHarnessId: sessionRuntimeOverride, - agentHarnessRuntimeOverride: sessionRuntimeOverride, - ...selectedAuthProfile, - thinkLevel: candidateThinkLevel, - fastMode: candidateFastMode.fastMode, - fastModeStartedAtMs, - fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds, - fastModeAutoProgressState, - verboseLevel: run.verboseLevel, - reasoningLevel: run.reasoningLevel, - suppressToolErrorWarnings: shouldSuppressToolErrorWarnings, - execOverrides: run.execOverrides, - bashElevated: run.bashElevated, - timeoutMs: run.timeoutMs, - runTimeoutOverrideMs: run.runTimeoutOverrideMs, - runId, - isFinalFallbackAttempt: runOptions?.isFinalFallbackAttempt, - abortSignal: runAbortSignal, - deferTerminalLifecycle: true, - onExecutionStarted: (info) => { - if (info?.lifecycleGeneration) { - lifecycleGeneration = info.lifecycleGeneration; - } - }, - images: queuedImages, - imageOrder: queuedImageOrder, - media: queuedMedia, - allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe, - blockReplyBreak: run.blockReplyBreak, - bootstrapPromptWarningSignaturesSeen, - bootstrapPromptWarningSignature: - bootstrapPromptWarningSignaturesSeen[ - bootstrapPromptWarningSignaturesSeen.length - 1 - ], - toolProgressDetail, - shouldEmitToolResult: shouldEmitToolResultProgress, - shouldEmitToolOutput: shouldEmitToolOutputProgress, - onToolResult: deliverFollowupToolSummary, - onAgentEvent: (evt) => { - replyOperation?.recordActivity(); - lifecycleBackstop.note(evt); - return enqueueProgressDelivery(async () => { - const visible = await forwardFollowupProgressEvent({ - evt, - opts: progressOpts, - detailMode: toolProgressDetail, - emitChannelProgress: shouldEmitToolResultProgress(), - onCompactionComplete: () => { - attemptCompactionCount += 1; - }, - notifyUserAboutCompaction: shouldNotifyUserAboutCompaction(runtimeConfig), - currentMessageId: compactionNoticeReplyToId, - onCompactionNoticePayload: (payload) => - sendCompactionNoticePayload(payload, { provider, modelId: model }), - }); - if (visible && hasFailedFollowupProgressEvent(evt)) { - markVisibleToolErrorProgress(); - } - }); - }, - }); - bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen( - result.meta?.systemPromptReport, - ); - const resultCompactionCount = Math.max( - 0, - result.meta?.agentMeta?.compactionCount ?? 0, - ); - attemptCompactionCount = Math.max(attemptCompactionCount, resultCompactionCount); - return result; - } finally { - autoCompactionCount += attemptCompactionCount; - } - }, - }); - runResult = fallbackResult.result; - fallbackProvider = fallbackResult.provider; - fallbackModel = fallbackResult.model; - fallbackExhausted = fallbackResult.outcome === "exhausted"; - const settledLifecycleTerminal = - pendingLifecycleTerminal?.provider === fallbackProvider && - pendingLifecycleTerminal.model === fallbackModel - ? pendingLifecycleTerminal.backstop - : undefined; - pendingLifecycleTerminal = undefined; - if (isAgentRunRestartAbortReason(runAbortSignal.reason)) { - settledLifecycleTerminal?.emit("end", runResult); - throw runAbortSignal.reason; - } - if ( - replyOperation.result?.kind === "aborted" && - replyOperation.result.code === "aborted_by_user" - ) { - settledLifecycleTerminal?.emit("end", runResult); - await drainProgressDeliveries(); - return; - } - replyOperation.freezeAbort(); - const emitSettledLifecycleError = (error: Error, extraData?: Record) => { - if (settledLifecycleTerminal) { - settledLifecycleTerminal.emit("error", error, extraData); - return; - } - emitAgentEvent({ - runId, - lifecycleGeneration, - ...(replySessionKey ? { sessionKey: replySessionKey } : {}), - stream: "lifecycle", - data: { - phase: "error", - error: error.message, - endedAt: Date.now(), - ...extraData, - }, - }); - }; - const deferredLifecycleError = settledLifecycleTerminal?.getDeferredError(); - const userFacingErrorPayload = runResult.payloads?.find( - (payload) => payload.isError === true && typeof payload.text === "string", - )?.text; - const terminalErrorMessage = - deferredLifecycleError ?? - userFacingErrorPayload ?? - (runResult.meta?.error ? "Agent run failed" : undefined); - const terminalMetadata = fallbackResult.terminal.metadata; - if (fallbackExhausted) { - const exhaustionError = new Error( - terminalErrorMessage ?? "All model fallback candidates failed", - ); - emitSettledLifecycleError(exhaustionError, { - ...terminalMetadata, - fallbackExhaustedFailure: true, - }); - replyOperation.fail("run_failed", exhaustionError); - terminalRunFailed = true; - } else if (deferredLifecycleError || runResult.meta?.error) { - const terminalError = new Error(terminalErrorMessage ?? "Agent run failed"); - emitSettledLifecycleError(terminalError, terminalMetadata); - replyOperation.fail("run_failed", terminalError); - terminalRunFailed = true; - } else { - settledLifecycleTerminal?.emit("end", runResult); - } - if (!fallbackExhausted) { - await fallbackResult.settleSessionOverride(); - } - } catch (err) { - if ( - replyOperation.result?.kind === "aborted" && - replyOperation.result.code === "aborted_by_user" - ) { - pendingLifecycleTerminal?.backstop.emit("error", err); - pendingLifecycleTerminal = undefined; - if (lifecycleGeneration !== getAgentEventLifecycleGeneration()) { - clearAgentRunContext(runId, lifecycleGeneration); - } - await drainProgressDeliveries(); - return; - } - const message = formatErrorMessage(err); - const shouldRouteFallbackExhaustion = isFallbackSummaryError(err); - replyOperation.freezeAbort(); - replyOperation.fail("run_failed", err); - pendingLifecycleTerminal?.backstop.emit("error", err); - pendingLifecycleTerminal = undefined; - if (lifecycleGeneration !== getAgentEventLifecycleGeneration()) { - clearAgentRunContext(runId, lifecycleGeneration); - } - defaultRuntime.error?.(`Followup agent failed before reply: ${message}`); - if (!shouldRouteFallbackExhaustion) { - await drainProgressDeliveries(); - return; - } - // Fallback exhaustion can throw without preserving a candidate result. - // Continue through the owner delivery path so interactive turns still get safe failure copy. - runResult = { payloads: [], meta: { durationMs: 0 } }; - fallbackExhausted = true; - terminalRunFailed = true; - } - - await drainProgressDeliveries(); - - const usage = runResult.meta?.agentMeta?.usage; - const promptTokens = runResult.meta?.agentMeta?.promptTokens; - const modelUsed = runResult.meta?.agentMeta?.model ?? fallbackModel ?? defaultModel; - const providerUsed = - runResult.meta?.agentMeta?.provider ?? fallbackProvider ?? queued.run.provider; - const usedCliProvider = isCliProvider(providerUsed, runtimeConfig); - const contextTokensUsed = - resolveContextTokensForModel({ - cfg: queued.run.config, - provider: providerUsed, - model: modelUsed, - contextTokensOverride: agentCfgContextTokens, - fallbackContextTokens: activeSessionEntry?.contextTokens ?? DEFAULT_CONTEXT_TOKENS, - allowAsyncLoad: false, - }) ?? DEFAULT_CONTEXT_TOKENS; - const deliverStrandedReplyRetryFailureDiagnostic = async () => { - if (!isStrandedReplyRetryFollowup(effectiveQueued)) { - return false; - } - const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ - cfg: runtimeConfig, - ctx: { - ChatType: queued.originatingChatType ?? run.chatType, - InboundEventKind: queued.currentInboundEventKind, - Provider: queued.originatingChannel ?? run.messageProvider, - Surface: queued.originatingChannel ?? run.messageProvider, - }, - requested: run.sourceReplyDeliveryMode ?? opts?.sourceReplyDeliveryMode, - sendPolicy: resolveSendPolicy({ - cfg: runtimeConfig, - entry: activeSessionEntry, - sessionKey: run.runtimePolicySessionKey ?? replySessionKey, - channel: - queued.originatingChannel ?? - run.messageProvider ?? - sessionDeliveryChannel(activeSessionEntry), - chatType: activeSessionEntry?.chatType, - }), - }); - if (sourceReplyPolicy.sendPolicyDenied) { - return false; - } - if ( - hasSuccessfulFollowupSourceReplyDelivery({ - didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool, - messagingToolSentTargets: runResult.messagingToolSentTargets, - messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads, - }) - ) { - await opts?.onObservedReplyDelivery?.(); - return false; - } - await sendFollowupPayloads( - [buildStrandedReplyDeliveryFailurePayload()], - effectiveQueued, - { - provider: providerUsed, - modelId: modelUsed, - }, - { runId }, - ); - return true; - }; - const enqueueStrandedReplyRecoveryRetry = async () => { - if (isStrandedReplyRetryFollowup(effectiveQueued)) { - return false; - } - // Heartbeat turns can reach this path: runReplyAgent builds the - // followup runner with opts.isHeartbeat and may enqueue-followup while - // another run is active. Heartbeats already deliver fallback finals - // via sendDurableMessageBatch, so recovery would duplicate delivery. - if (opts?.isHeartbeat === true) { - return false; - } - const sourceReplyPolicy = resolveSourceReplyVisibilityPolicy({ - cfg: runtimeConfig, - ctx: { - ChatType: queued.originatingChatType ?? run.chatType, - InboundEventKind: queued.currentInboundEventKind, - Provider: queued.originatingChannel ?? run.messageProvider, - Surface: queued.originatingChannel ?? run.messageProvider, - }, - requested: run.sourceReplyDeliveryMode ?? opts?.sourceReplyDeliveryMode, - sendPolicy: resolveSendPolicy({ - cfg: runtimeConfig, - entry: activeSessionEntry, - sessionKey: run.runtimePolicySessionKey ?? replySessionKey, - channel: - queued.originatingChannel ?? - run.messageProvider ?? - sessionDeliveryChannel(activeSessionEntry), - chatType: activeSessionEntry?.chatType, - }), - }); - const assistantFinalText = - typeof runResult.meta?.finalAssistantVisibleText === "string" - ? normalizeAssistantFinalDeliveryText(runResult.meta.finalAssistantVisibleText) - : ""; - const isStrandedReply = - queued.currentInboundEventKind !== "room_event" && - shouldWarnAboutPrivateMessageToolFinal({ - sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode, - sendPolicyDenied: sourceReplyPolicy.sendPolicyDenied, - successfulSourceReplyDelivery: hasSuccessfulFollowupSourceReplyDelivery({ - didDeliverSourceReplyViaMessageTool: runResult.didDeliverSourceReplyViaMessageTool, - messagingToolSentTargets: runResult.messagingToolSentTargets, - messagingToolSourceReplyPayloads: runResult.messagingToolSourceReplyPayloads, - }), - finalText: assistantFinalText, - }); - if (!isStrandedReply) { - return false; - } - warnPrivateMessageToolFinal({ - sessionKey: replySessionKey, - channel: - queued.originatingChannel ?? - run.messageProvider ?? - sessionDeliveryChannel(activeSessionEntry), - finalTextLength: assistantFinalText.trim().length, - }); - const retryEnqueued = - typeof replySessionKey === "string" && - replySessionKey.length > 0 && - enqueueFollowupRun( - replySessionKey, - buildStrandedReplyRetryFollowupRun(effectiveQueued, { - finalText: assistantFinalText, - sourceReplyDeliveryMode: sourceReplyPolicy.sourceReplyDeliveryMode, - }), - resolveQueueSettings({ - cfg: runtimeConfig, - channel: queued.originatingChannel ?? run.messageProvider, - sessionEntry: activeSessionEntry, - }), - "none", - runFollowupTurn, - false, - { position: "front" }, - ); - if (!retryEnqueued) { - await sendFollowupPayloads( - [buildStrandedReplyDeliveryFailurePayload()], - effectiveQueued, - { - provider: providerUsed, - modelId: modelUsed, - }, - { runId }, - ); - } - return true; - }; - if (storePath && replySessionKey) { - await persistRunSessionUsage({ - storePath, - sessionKey: replySessionKey, - cfg: runtimeConfig, - usage, - lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage, - compactionTokensAfter: runResult.meta?.agentMeta?.compactionTokensAfter, - promptTokens, - isHeartbeat: opts?.isHeartbeat === true, - preserveRuntimeModel: fallbackExhausted, - preserveUserFacingSessionModelState: preserveUserFacingSessionState, - modelUsed, - providerUsed, - contextTokensUsed, - systemPromptReport: runResult.meta?.systemPromptReport, - cliSessionBinding: runResult.meta?.agentMeta?.cliSessionBinding, - clearCliSessionBinding: - usedCliProvider && runResult.meta?.agentMeta?.clearCliSessionBinding === true, - preserveFreshTotalTokensOnStaleUsage: preflightCompactionApplied, - logLabel: "followup", - }); - } - const hasCommittedDelivery = - hasVisibleOutboundDeliveryEvidence(runResult) || - hasCommittedSourceReplyDeliveryEvidence(runResult) || - runResult.didSendDeterministicApprovalPrompt === true; - const hasCompletedTerminalDelivery = hasCompletedTerminalDeliveryEvidence(runResult); - const hasDeliveryDestination = Boolean( - (isRoutableChannel(queued.originatingChannel) && queued.originatingTo) || - opts?.onBlockReply, - ); - const isInteractive = - hasDeliveryDestination && - queued.currentInboundEventKind !== "room_event" && - (run.inputProvenance?.kind === undefined || run.inputProvenance.kind === "external_user"); - const failureConversationContext = { - ChatType: queued.originatingChatType, - Provider: run.messageProvider, - SessionKey: replySessionKey, - Surface: queued.originatingChannel, - }; - const fallbackPayload = terminalRunFailed - ? isInteractive && !hasCompletedTerminalDelivery - ? buildTerminalAgentRunFailureReplyPayload({ - isHeartbeat: opts?.isHeartbeat, - sessionCtx: failureConversationContext, - cfg: runtimeConfig, - }) - : undefined - : buildEmptyInteractiveReplyPayload({ - isInteractive, - isHeartbeat: opts?.isHeartbeat, - silentExpected: run.silentExpected, - allowEmptyAssistantReplyAsSilent: run.allowEmptyAssistantReplyAsSilent, - isMessageToolOnly: run.sourceReplyDeliveryMode === "message_tool_only", - hasPendingContinuation: - runResult.meta?.yielded === true || - (runResult.meta?.pendingToolCalls?.length ?? 0) > 0, - hasExplicitSilentReply: hasDeliberateSilentTerminalReply(runResult), - hasCommittedDelivery, - sessionCtx: failureConversationContext, - cfg: runtimeConfig, - }); - const deliveryPlan = buildAgentRuntimeDeliveryPlan({ - provider: providerUsed, - modelId: modelUsed, - config: runtimeConfig, - workspaceDir: run.workspaceDir, - agentDir: run.agentDir, }); - const resolveDeliveryPayloads = (payloads: ReplyPayload[]) => - resolveFollowupDeliveryPayloads({ - cfg: runtimeConfig, - payloads, - messageProvider: run.messageProvider, - originatingAccountId: queued.originatingAccountId ?? run.agentAccountId, - originatingChannel: queued.originatingChannel, - originatingChatType: queued.originatingChatType, - originatingReplyToMode: queued.originatingReplyToMode, - originatingTo: queued.originatingTo, - originatingThreadId: queued.originatingThreadId, - reasoningPayloadsEnabled: opts?.reasoningPayloadsEnabled === true, - commentaryPayloadsEnabled: opts?.commentaryPayloadsEnabled === true, - sentMediaUrls: runResult.messagingToolSentMediaUrls, - sentTargets: runResult.messagingToolSentTargets, - sentTexts: runResult.messagingToolSentTexts, - }).filter( - (payload) => hasOutboundReplyContent(payload) && !deliveryPlan.isSilentPayload(payload), - ); - let finalPayloads = resolveDeliveryPayloads(runResult.payloads ?? []); - const hasTerminalReplyPayload = finalPayloads.some( - (payload) => - payload.isReasoning !== true && - payload.isCommentary !== true && - !isReplyPayloadStatusNotice(payload), - ); - if (!hasTerminalReplyPayload && fallbackPayload) { - finalPayloads = [...finalPayloads, ...resolveDeliveryPayloads([fallbackPayload])]; + switch (admission.kind) { + case "deferred": + throw new FollowupRunDeferredError( + `Follow-up reply lane is still active (${admission.reason})`, + ); + case "skipped": + operation = admission.operation; + disposition = { kind: "consumed" }; + return; + case "admitted": + break; } - if (finalPayloads.length === 0) { - if (await enqueueStrandedReplyRecoveryRetry()) { - return; - } - if (await deliverStrandedReplyRetryFailureDiagnostic()) { - return; - } - return; + const turn: AdmittedFollowupTurn = admission.turn; + admittedRunId = turn.runId; + operation = turn.operation; + const execution = await executeFollowupTurn({ + turn, + defaults, + onExecutionStarted: () => { + executionStarted = true; + }, + onToolResult: async (payload, identity) => { + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [payload] }, + turn, + defaults, + runId: identity.runId, + runFollowup, + kind: "tool", + }); + }, + onCompactionNoticePayload: async (payload, identity) => { + await deliverFollowupDecision({ + decision: { kind: "deliver", payloads: [payload] }, + turn, + defaults, + runId: identity.runId, + runFollowup, + kind: "block", + }); + }, + }); + try { + await execution.progress.drain(); + } catch (error) { + // Execution already settled; replaying the queued prompt could duplicate side effects. + defaultRuntime.error?.( + `followup queue: progress presentation failed after execution: ${formatErrorMessage(error)}`, + ); + operation.fail("run_failed", error); } if ( - !terminalRunFailed && - fallbackPayload && - finalPayloads.some( - (payload) => payload.isError === true && payload.text === fallbackPayload.text, - ) + execution.execution.outcome.kind === "settled" && + hasCompletedSourceReplyDeliveryEvidence(execution.execution.outcome.result) ) { - replyOperation.fail( - "run_failed", - new Error("interactive follow-up completed without a visible reply"), - ); + await defaults.opts?.onObservedReplyDelivery?.(); } - let deliveryPayloads = finalPayloads; - const responseUsageSessionRaw = - activeSessionEntry?.responseUsage ?? - (replySessionKey ? sessionStore?.[replySessionKey]?.responseUsage : undefined); - const winnerProvider = fallbackExhausted - ? undefined - : (runResult.meta?.executionTrace?.winnerProvider ?? providerUsed); - const winnerModel = fallbackExhausted - ? undefined - : (runResult.meta?.executionTrace?.winnerModel ?? modelUsed); - const lastCallUsage = runResult.meta?.agentMeta?.lastCallUsage; - const replyUsageState = buildReplyUsageState({ - config: runtimeConfig, - provider: providerUsed, - model: modelUsed, - fallbackExhausted, - winnerProvider, - winnerModel, - reasoningEffort: typeof run.thinkLevel === "string" ? run.thinkLevel : undefined, - fallbackUsed: runResult.meta?.executionTrace?.fallbackUsed === true, - agentId: run.agentId, - sessionId: run.sessionId, - chatType: queued.originatingChatType, - authMode: runResult.meta?.requestShaping?.authMode ?? undefined, - overrideSource: activeSessionEntry?.modelOverrideSource ?? undefined, - requestedProvider: run.provider, - requestedModel: run.model, - compactionCount: - typeof runResult.meta?.agentMeta?.compactionCount === "number" - ? runResult.meta.agentMeta.compactionCount - : undefined, - contextTokenBudget: - typeof contextTokensUsed === "number" && Number.isFinite(contextTokensUsed) - ? contextTokensUsed - : undefined, - promptTokens, - usage, - lastCallUsage, + const accounting = await accountFollowupTurn({ turn, defaults, execution }); + const decision = resolveFollowupDeliveryDecision({ + turn, + execution: execution.execution, + accounting, + opts: defaults.opts, }); - const responseUsageLine = resolveResponseUsageLine({ - config: runtimeConfig, - sessionRaw: responseUsageSessionRaw, - channel: resolveOriginMessageProvider({ - originatingChannel: queued.originatingChannel, - provider: run.messageProvider, - }), - usage, - provider: providerUsed, - model: modelUsed, - preserveUserFacingSessionState, - replyUsageState, + await deliverFollowupDecision({ + decision, + turn, + defaults, + runId: execution.execution.runId, + runFollowup, }); - if (responseUsageLine) { - deliveryPayloads = appendUsageLine(deliveryPayloads, responseUsageLine); - } - if (autoCompactionCount > 0) { - const previousSessionId = run.sessionId; - const count = await incrementRunCompactionCount({ - cfg: runtimeConfig, - sessionEntry: activeSessionEntry, - sessionStore, - sessionKey: replySessionKey, - storePath, - amount: autoCompactionCount, - compactionTokensAfter: runResult.meta?.agentMeta?.compactionTokensAfter, - lastCallUsage: runResult.meta?.agentMeta?.lastCallUsage, - contextTokensUsed, - newSessionId: runResult.meta?.agentMeta?.sessionId, - newSessionFile: runResult.meta?.agentMeta?.sessionFile, - }); - const refreshedSessionEntry = - replySessionKey && sessionStore ? sessionStore[replySessionKey] : undefined; - if (refreshedSessionEntry) { - const queueKey = run.sessionKey ?? sessionKey; - if (queueKey) { - refreshQueuedFollowupSession({ - key: queueKey, - previousSessionId, - nextSessionId: refreshedSessionEntry.sessionId, - nextSessionFile: refreshedSessionEntry.sessionFile, - }); - } - } - if (shouldEmitVerboseProgress()) { - const suffix = typeof count === "number" ? ` (count ${count})` : ""; - deliveryPayloads = [ - { - text: `🧹 Auto-compaction complete${suffix}.`, - }, - ...deliveryPayloads, - ]; - } - } - if (run.sourceReplyDeliveryMode === "message_tool_only") { - const suppressionDeliverablePayloads = deliveryPayloads.filter( - (payload) => - getReplyPayloadMetadata(payload)?.deliverDespiteSourceReplySuppression === true, + disposition = { kind: "consumed" }; + } catch (error) { + if (error instanceof FollowupRunDeferredError) { + disposition = { kind: "deferred", reason: error.message }; + } else if ( + operation?.result?.kind === "aborted" && + operation.result.code === "aborted_by_user" + ) { + disposition = { kind: "consumed" }; + } else if (executionStarted) { + // There is no durable post-execution resume record yet. Requeueing the prompt + // here can duplicate persisted turns and external tool side effects. Preserve + // the terminal failure through complete() rather than reporting success. + defaultRuntime.error?.( + `followup queue: execution failed after start; refusing replay: ${formatErrorMessage(error)}`, ); - if (suppressionDeliverablePayloads.length > 0) { - // Marked runtime output bypasses source-reply suppression, not the - // admission-time send policy or ambient room-event silence. - if (isRoomEventFollowup()) { - return; - } - await sendRunPayloads( - suppressionDeliverablePayloads, - effectiveQueued, - { - provider: providerUsed, - modelId: modelUsed, - }, - { runId }, - ); - return; - } - if (await enqueueStrandedReplyRecoveryRetry()) { - return; - } - if (await deliverStrandedReplyRetryFailureDiagnostic()) { - return; - } - logVerbose( - "followup queue: automatic source delivery suppressed by sourceReplyDeliveryMode: message_tool_only", - ); - return; + operation?.fail("run_failed", error); + disposition = { kind: "consumed" }; + } else { + disposition = { kind: "retry", error }; } - await sendRunPayloads( - deliveryPayloads, - effectiveQueued, - { - provider: providerUsed, - modelId: modelUsed, - }, - { runId }, - ); - } catch (err) { - failed = true; - throw err; } finally { for (const end of endDeliveryCorrelations.toReversed()) { try { end(); - } catch (err) { + } catch (error) { defaultRuntime.error?.( - `followup queue: delivery correlation cleanup failed: ${formatErrorMessage(err)}`, + `followup queue: delivery correlation cleanup failed: ${formatErrorMessage(error)}`, ); } } - // A thrown attempt stays in the drain queue for retry. Its lifecycle - // identity remains live until the drain later consumes or drops it. - if (!deferred && !failed) { + if (disposition.kind === "consumed") { completeFollowupRunLifecycle(queued); + if (admittedRunId) { + clearAgentRunContext(admittedRunId); + } + } else if (disposition.kind === "retry" && admittedRunId) { + clearAgentRunContext(admittedRunId); } - replyOperation?.complete(); - // Both signals are required for the typing controller to clean up. - // The main inbound dispatch path calls markDispatchIdle() from the - // buffered dispatcher's finally block, but followup turns bypass the - // dispatcher entirely — so we must fire both signals here. Without - // this, NO_REPLY / empty-payload followups leave the typing indicator - // stuck (the keepalive loop keeps sending "typing" to Telegram - // indefinitely until the TTL expires). - typing.markRunComplete(); - typing.markDispatchIdle(); + operation?.complete(); + defaults.typing.markRunComplete(); + defaults.typing.markDispatchIdle(); + } + if (disposition.kind === "deferred") { + throw new FollowupRunDeferredError( + `Follow-up reply lane is still active (${disposition.reason})`, + ); + } + if (disposition.kind === "retry") { + throw disposition.error; } }; - return runFollowupTurn; + return runFollowup; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/src/auto-reply/reply/followup-turn-admission.test.ts b/src/auto-reply/reply/followup-turn-admission.test.ts new file mode 100644 index 000000000000..4eed723e94ff --- /dev/null +++ b/src/auto-reply/reply/followup-turn-admission.test.ts @@ -0,0 +1,1106 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { SessionEntry } from "../../config/sessions.js"; +import type { FollowupRun } from "./queue.js"; + +const state = vi.hoisted(() => ({ + admitLifecycle: vi.fn(), + admitReply: vi.fn(), + buildPreflightFailureText: vi.fn(), + loadEntry: vi.fn(), + preflight: vi.fn(), + recheckFallbackProbe: vi.fn(), + refreshGoal: vi.fn(), + resolveConfig: vi.fn(), + resolveSendPolicy: vi.fn(), + sendPolicy: "allow" as "allow" | "deny", + shouldNotifyCompaction: false, +})); + +vi.mock("./agent-runner-auto-fallback.js", () => ({ + resolveRunAfterAutoFallbackPrimaryProbeRecheck: (...args: unknown[]) => + state.recheckFallbackProbe(...args), +})); + +vi.mock("./agent-runner-memory.js", () => ({ + runPreflightCompactionIfNeeded: (...args: unknown[]) => state.preflight(...args), +})); + +vi.mock("./agent-runner-utils.js", () => ({ + resolveQueuedReplyExecutionConfig: (...args: unknown[]) => state.resolveConfig(...args), + resolveQueuedReplyRuntimeConfig: (config: unknown) => config, +})); + +vi.mock("./reply-turn-admission.js", () => ({ + admitReplyTurn: (...args: unknown[]) => state.admitReply(...args), +})); + +vi.mock("./queue.js", () => ({ + admitFollowupRunLifecycle: (...args: unknown[]) => state.admitLifecycle(...args), + isFollowupRunAborted: (run: FollowupRun) => + run.abortSignal?.aborted === true || run.queueAbortSignal?.aborted === true, + resolveFollowupAbortSignal: (run: FollowupRun) => run.abortSignal ?? run.queueAbortSignal, +})); + +vi.mock("../../config/sessions/session-accessor.js", () => ({ + loadSessionEntry: (...args: unknown[]) => state.loadEntry(...args), +})); + +vi.mock("../../sessions/send-policy.js", () => ({ + resolveSendPolicy: (...args: unknown[]) => state.resolveSendPolicy(...args), +})); + +vi.mock("./inbound-meta.js", () => ({ + refreshActiveGoalContext: (...args: unknown[]) => state.refreshGoal(...args), +})); + +vi.mock("./compaction-notice.js", () => ({ + createCompactionNoticePayload: ({ phase }: { phase: string }) => ({ text: phase }), + shouldNotifyUserAboutCompaction: () => state.shouldNotifyCompaction, +})); + +vi.mock("./agent-runner-failure-reply.js", () => ({ + buildPreflightCompactionFailureText: (...args: unknown[]) => + state.buildPreflightFailureText(...args), +})); + +const { admitFollowupTurn } = await import("./followup-turn-admission.js"); + +function createRun(overrides: Partial = {}): FollowupRun { + return { + prompt: "queued prompt", + enqueuedAt: 1, + run: { + agentId: "agent", + agentDir: "/tmp/agent", + sessionId: "queued-session", + sessionKey: "main", + sessionFile: "/tmp/queued.jsonl", + workspaceDir: "/tmp", + config: {}, + provider: "anthropic", + model: "claude", + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + ...overrides, + }; +} + +function createOperation(sessionId = "queued-session") { + return { + sessionId, + abortForRestart: vi.fn(() => true), + retainFailureUntilComplete: vi.fn(), + fail: vi.fn(), + complete: vi.fn(), + updateSessionId: vi.fn(), + }; +} + +function createDefaults(overrides: Record = {}) { + return { + typing: {} as never, + typingMode: "never" as const, + defaultModel: "claude", + sessionKey: "main", + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + state.sendPolicy = "allow"; + state.shouldNotifyCompaction = false; + state.resolveSendPolicy.mockImplementation(() => state.sendPolicy); + state.resolveConfig.mockImplementation(async (config) => config); + state.buildPreflightFailureText.mockReturnValue("preflight failed"); + state.preflight.mockImplementation(async ({ sessionEntry }) => sessionEntry); + state.recheckFallbackProbe.mockImplementation(({ run }) => run); + state.admitLifecycle.mockResolvedValue(undefined); + state.refreshGoal.mockImplementation((context) => context); +}); + +describe("admitFollowupTurn", () => { + it("returns a closed deferral without adopting the queued source", async () => { + state.admitReply.mockResolvedValue({ status: "skipped", reason: "active-run" }); + + await expect( + admitFollowupTurn({ queued: createRun(), defaults: createDefaults() }), + ).resolves.toEqual({ kind: "deferred", reason: "active-run" }); + expect(state.admitLifecycle).not.toHaveBeenCalled(); + }); + + it("stops after asynchronous source adoption aborts the aggregate owner", async () => { + const controller = new AbortController(); + const operation = createOperation(); + state.admitReply.mockResolvedValue({ status: "owned", operation }); + state.admitLifecycle.mockImplementation(async () => controller.abort()); + + await expect( + admitFollowupTurn({ + queued: createRun({ queueAbortSignal: controller.signal }), + defaults: createDefaults(), + }), + ).resolves.toMatchObject({ kind: "skipped", reason: "aborted", operation }); + expect(state.preflight).not.toHaveBeenCalled(); + }); + + it("uses admission-time session generation, model lock, policy, and goal context", async () => { + const operation = createOperation("admitted-session"); + const queuedEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const admittedEntry: SessionEntry = { + sessionId: "admitted-session", + sessionFile: "/tmp/admitted.jsonl", + modelSelectionLocked: true, + updatedAt: 2, + }; + const sessionStore = { main: queuedEntry }; + const onQueuedFollowupAdmitted = vi.fn(async () => {}); + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: admittedEntry }); + state.loadEntry.mockReturnValue(admittedEntry); + state.sendPolicy = "deny"; + state.refreshGoal.mockReturnValue({ text: "fresh goal" }); + state.preflight.mockImplementation(async ({ sessionEntry }) => sessionEntry); + + const result = await admitFollowupTurn({ + queued: createRun({ + currentInboundContext: { text: "stale goal" }, + originatingChatType: "group", + }), + defaults: createDefaults({ + sessionEntry: queuedEntry, + sessionStore, + storePath: "/tmp/sessions.json", + opts: { onQueuedFollowupAdmitted }, + }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.queued.run).toMatchObject({ + sessionId: "admitted-session", + sessionFile: "sqlite:agent:admitted-session:/tmp/sessions.json", + modelSelectionLocked: true, + }); + expect(result.turn.currentInboundContext).toEqual({ text: "fresh goal" }); + expect(result.turn.sendPolicy).toBe("deny"); + expect(result.turn.session.current()).toBe(admittedEntry); + } + expect(onQueuedFollowupAdmitted).toHaveBeenCalledOnce(); + expect(operation.retainFailureUntilComplete).toHaveBeenCalledOnce(); + expect(state.resolveSendPolicy).toHaveBeenCalledWith( + expect.objectContaining({ chatType: "group" }), + ); + }); + + it("prefers the admitted snapshot over unchanged stale in-memory state", async () => { + const operation = createOperation("admitted-session"); + const queuedEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const admittedEntry: SessionEntry = { + sessionId: "admitted-session", + modelSelectionLocked: true, + updatedAt: 2, + }; + const sessionStore = { main: queuedEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: admittedEntry }); + state.preflight.mockImplementation(async ({ sessionEntry }) => sessionEntry); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: queuedEntry, sessionStore }), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { + queued: { run: { sessionId: "admitted-session", modelSelectionLocked: true } }, + }, + }); + if (result.kind === "admitted") { + expect(result.turn.session.current()).toBe(admittedEntry); + } + }); + + it("clears queued generation facts when only the lifecycle revision advances", async () => { + const operation = createOperation(); + const queuedEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "queued-revision", + updatedAt: 1, + }; + const admittedEntry: SessionEntry = { + ...queuedEntry, + lifecycleRevision: "admitted-revision", + updatedAt: 2, + }; + const sessionStore = { main: queuedEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: admittedEntry }); + state.loadEntry.mockReturnValue(admittedEntry); + const queued = createRun(); + queued.run.cliSessionBindingFacts = { provider: "claude-cli" } as never; + queued.run.autoFallbackPrimaryProbe = { provider: "anthropic", model: "claude" } as never; + + const result = await admitFollowupTurn({ + queued, + defaults: createDefaults({ sessionEntry: queuedEntry, sessionStore }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.queued.run.cliSessionBindingFacts).toBeUndefined(); + expect(result.turn.queued.run.autoFallbackPrimaryProbe).toBeUndefined(); + } + }); + + it("releases the reply operation when post-admission dispatcher setup fails", async () => { + const operation = createOperation(); + const failure = new Error("dispatcher reset failed"); + state.admitReply.mockResolvedValue({ status: "owned", operation }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ + opts: { + onQueuedFollowupAdmitted: vi.fn(async () => { + throw failure; + }), + }, + }), + }), + ).rejects.toBe(failure); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("does not pass stale enqueue-time state into a rotated session generation", async () => { + const operation = createOperation("rotated-session"); + const staleEntry: SessionEntry = { + sessionId: "queued-session", + modelSelectionLocked: true, + updatedAt: 1, + }; + const sessionStore = { main: staleEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation }); + state.loadEntry.mockReturnValue(undefined); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: staleEntry, sessionStore }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.queued.run.modelSelectionLocked).toBe(false); + } + expect(state.preflight).toHaveBeenCalledWith( + expect.objectContaining({ sessionEntry: undefined }), + ); + const preflightParams = state.preflight.mock.calls[0]?.[0] as { + sessionStore?: Record; + }; + expect(preflightParams.sessionStore?.main).toBeUndefined(); + expect(state.refreshGoal).toHaveBeenCalledWith(undefined, undefined); + }); + + it("restores the item when persisted state changes generation after admission", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const replacementEntry: SessionEntry = { sessionId: "replacement-session", updatedAt: 2 }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(replacementEntry); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + }), + ).rejects.toThrow("Follow-up session generation changed after reply admission"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.preflight).not.toHaveBeenCalled(); + }); + + it("restores the item when persisted lifecycle revision changes after admission", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "admitted", + updatedAt: 1, + }; + const replacementEntry: SessionEntry = { + ...initialEntry, + lifecycleRevision: "replacement", + updatedAt: 2, + }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(replacementEntry); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + }), + ).rejects.toThrow("Follow-up session generation changed after reply admission"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.preflight).not.toHaveBeenCalled(); + }); + + it("restores the item when an in-memory generation changes while admission awaits", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "admitted", + updatedAt: 1, + }; + const replacementEntry: SessionEntry = { + sessionId: "replacement-session", + lifecycleRevision: "replacement", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + const onQueuedFollowupAdmitted = vi.fn(async () => { + sessionStore.main = replacementEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ + sessionEntry: initialEntry, + sessionStore, + opts: { onQueuedFollowupAdmitted }, + }), + }), + ).rejects.toThrow("Follow-up session generation changed after reply admission"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.preflight).not.toHaveBeenCalled(); + }); + + it("restores the item when the admitted persisted generation disappears", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(undefined); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + }), + ).rejects.toThrow("Follow-up session generation changed after reply admission"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.preflight).not.toHaveBeenCalled(); + }); + + it("advances the owned lifecycle generation only through explicit adoption", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "revision-a", + updatedAt: 1, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + const replacement = { + ...initialEntry, + lifecycleRevision: "revision-b", + updatedAt: 2, + }; + result.turn.session.adopt(replacement); + result.turn.session.publish(initialEntry); + expect(result.turn.session.current()).toBe(replacement); + const newerSameGeneration = { + ...replacement, + updatedAt: 4, + }; + result.turn.session.adopt(newerSameGeneration); + result.turn.session.publish(replacement); + expect(result.turn.session.current()).toBe(newerSameGeneration); + + const concurrentEntry = { + ...initialEntry, + lifecycleRevision: "revision-c", + updatedAt: 3, + }; + sessionStore.main = concurrentEntry; + result.turn.session.publish(replacement); + expect(sessionStore.main).toBe(concurrentEntry); + } + }); + + it("does not treat an absent admitted lifecycle revision as a wildcard", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + sessionStore.main = { + ...initialEntry, + lifecycleRevision: "replacement", + updatedAt: 2, + }; + expect(result.turn.session.current()).toBe(initialEntry); + } + }); + + it("does not republish a generation deleted after admission", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "admitted", + updatedAt: 1, + }; + const sessionStore: Record = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + delete sessionStore.main; + result.turn.session.publish({ ...initialEntry, updatedAt: 2 }); + expect(sessionStore.main).toBeUndefined(); + } + }); + + it("creates an owned session-store view when only persisted state is available", async () => { + const operation = createOperation(); + const admittedEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: admittedEntry }); + state.loadEntry.mockReturnValue(admittedEntry); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ storePath: "/tmp/sessions.json" }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.sessionStore?.main).toBe(admittedEntry); + const updated = { ...admittedEntry, compactionCount: 1, updatedAt: 2 }; + result.turn.sessionStore!.main = updated; + expect(result.turn.session.current()).toBe(updated); + } + }); + + it("synchronizes a fresh admitted snapshot over same-generation stale memory", async () => { + const operation = createOperation(); + const staleEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "revision-a", + verboseLevel: "off", + updatedAt: 1, + }; + const freshEntry: SessionEntry = { + ...staleEntry, + verboseLevel: "full", + updatedAt: 2, + }; + const sessionStore = { main: staleEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: freshEntry }); + state.loadEntry.mockReturnValue(freshEntry); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionStore, sessionEntry: staleEntry }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.session.current()).toBe(freshEntry); + expect(sessionStore.main).toBe(freshEntry); + } + expect(state.refreshGoal).toHaveBeenCalledWith(undefined, freshEntry); + expect(state.recheckFallbackProbe).toHaveBeenCalledWith( + expect.objectContaining({ entry: freshEntry, sessionKey: "main" }), + ); + }); + + it("adopts a session generation rotated by owned preflight compaction", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + state.preflight.mockResolvedValue(rotatedEntry); + + const queued = createRun(); + queued.run.cliSessionBindingFacts = { provider: "claude-cli" } as never; + queued.run.autoFallbackPrimaryProbe = { provider: "anthropic", model: "claude" } as never; + queued.run.modelSelectionLocked = true; + const result = await admitFollowupTurn({ + queued, + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }); + + expect(result.kind).toBe("admitted"); + if (result.kind === "admitted") { + expect(result.turn.session.current()).toBe(rotatedEntry); + expect(result.turn.queued.run).toMatchObject({ + sessionId: "compacted-session", + modelSelectionLocked: false, + }); + expect(result.turn.queued.run.sessionFile).toContain("compacted-session"); + expect(result.turn.queued.run.sessionFile).not.toBe("/tmp/session.jsonl"); + expect(result.turn.queued.run.cliSessionBindingFacts).toBeUndefined(); + expect(result.turn.queued.run.autoFallbackPrimaryProbe).toBeUndefined(); + expect(result.turn.preflightCompactionApplied).toBe(true); + } + expect(operation.updateSessionId).toHaveBeenCalledWith("compacted-session"); + }); + + it("adopts a generation already published by owned preflight compaction", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + lifecycleRevision: "compacted", + updatedAt: 2, + }; + const publishedEntry: SessionEntry = { + ...rotatedEntry, + verboseLevel: "full", + updatedAt: 3, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + state.preflight.mockImplementation(async () => { + sessionStore.main = publishedEntry; + return rotatedEntry; + }); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }); + + expect(result).toMatchObject({ kind: "admitted" }); + if (result.kind === "admitted") { + expect(result.turn.session.current()).toBe(publishedEntry); + } + expect(state.resolveSendPolicy).toHaveBeenLastCalledWith( + expect.objectContaining({ entry: publishedEntry }), + ); + }); + + it("adopts a generation written through the owned preflight store view", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + lifecycleRevision: "compacted", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation( + async ({ sessionStore: ownedStore }: { sessionStore: Record }) => { + ownedStore.main = rotatedEntry; + return ownedStore.main; + }, + ); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { queued: { run: { sessionId: "compacted-session" } } }, + }); + expect(sessionStore.main).toBe(rotatedEntry); + }); + + it("forwards owned preflight deletion to the backing session store", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const sessionStore: Record = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation( + async ({ sessionStore: ownedStore }: { sessionStore: Record }) => { + delete ownedStore.main; + return initialEntry; + }, + ); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(sessionStore.main).toBeUndefined(); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("restores the item when preflight adoption races a replacement generation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + lifecycleRevision: "compacted", + updatedAt: 2, + }; + const replacementEntry: SessionEntry = { + sessionId: "replacement-session", + lifecycleRevision: "replacement", + updatedAt: 3, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + state.preflight.mockImplementation(async () => { + sessionStore.main = replacementEntry; + return rotatedEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.buildPreflightFailureText).not.toHaveBeenCalled(); + }); + + it("restores the item when a no-op preflight observes a replacement generation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const replacementEntry: SessionEntry = { + sessionId: "replacement-session", + lifecycleRevision: "replacement", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + state.preflight.mockImplementation(async () => { + sessionStore.main = replacementEntry; + return initialEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("restores the item when a successful preflight observes in-memory deletion", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const sessionStore: Record = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async () => { + delete sessionStore.main; + return initialEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("refreshes send policy and goal context after preflight rotates the generation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const rotatedEntry: SessionEntry = { sessionId: "compacted-session", updatedAt: 2 }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValue(initialEntry); + state.refreshGoal.mockImplementation((_context, entry) => ({ text: entry?.sessionId })); + state.preflight.mockImplementation(async () => { + state.sendPolicy = "deny"; + return rotatedEntry; + }); + + const result = await admitFollowupTurn({ + queued: createRun({ currentInboundContext: { text: "stale" } }), + defaults: createDefaults({ sessionStore, sessionEntry: initialEntry }), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { + sendPolicy: "deny", + currentInboundContext: { text: "compacted-session" }, + queued: { currentInboundContext: { text: "compacted-session" } }, + }, + }); + }); + + it("rechecks send policy before an in-preflight compaction notice", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const deniedEntry: SessionEntry = { ...initialEntry, updatedAt: 2 }; + const onCompactionNoticePayload = vi.fn(async () => {}); + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValueOnce(initialEntry).mockReturnValue(deniedEntry); + state.resolveSendPolicy.mockImplementation(({ entry }) => + entry === deniedEntry ? "deny" : "allow", + ); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + await onCompactionNotice?.("end"); + return deniedEntry; + }); + + await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + onCompactionNoticePayload, + }); + + expect(onCompactionNoticePayload).not.toHaveBeenCalled(); + expect(state.resolveSendPolicy).toHaveBeenLastCalledWith( + expect.objectContaining({ entry: deniedEntry }), + ); + }); + + it("delivers a terminal compaction notice after adopting its rotated generation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + lifecycleRevision: "compacted", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + const onCompactionNoticePayload = vi.fn(async () => {}); + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + sessionStore.main = rotatedEntry; + await onCompactionNotice?.("end"); + return rotatedEntry; + }); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + onCompactionNoticePayload, + }); + + expect(result).toMatchObject({ kind: "admitted" }); + expect(onCompactionNoticePayload).toHaveBeenCalledWith( + { text: "end" }, + expect.objectContaining({ + sendPolicy: "allow", + queued: expect.objectContaining({ + run: expect.objectContaining({ sessionId: "compacted-session" }), + }), + }), + ); + }); + + it("releases the admitted operation when deferred terminal notice delivery fails", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const failure = new Error("notice delivery failed"); + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + await onCompactionNotice?.("end"); + return initialEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry }), + onCompactionNoticePayload: vi.fn(async () => { + throw failure; + }), + }), + ).rejects.toBe(failure); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("delivers an incomplete terminal notice after ordinary preflight failure", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const onCompactionNoticePayload = vi.fn(async () => {}); + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + await onCompactionNotice?.("start"); + await onCompactionNotice?.("incomplete"); + throw new Error("preflight failed"); + }); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry }), + onCompactionNoticePayload, + }); + + expect(result).toMatchObject({ kind: "admitted", turn: { preflightFailurePayload: {} } }); + expect(onCompactionNoticePayload).toHaveBeenCalledTimes(2); + expect(onCompactionNoticePayload).toHaveBeenNthCalledWith( + 1, + { text: "start" }, + expect.anything(), + ); + expect(onCompactionNoticePayload).toHaveBeenNthCalledWith( + 2, + { text: "incomplete" }, + expect.anything(), + ); + }); + + it("keeps an owned preflight rotation when preflight later fails", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "initial", + updatedAt: 1, + }; + const rotatedEntry: SessionEntry = { + sessionId: "compacted-session", + lifecycleRevision: "compacted", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation( + async ({ sessionStore: ownedStore }: { sessionStore: Record }) => { + ownedStore.main = rotatedEntry; + throw new Error("preflight failed after rotation"); + }, + ); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { + queued: { run: { sessionId: "compacted-session" } }, + preflightFailurePayload: {}, + }, + }); + expect(operation.updateSessionId).toHaveBeenCalledWith("compacted-session"); + }); + + it("restores the item when generation changes before a compaction notice", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const replacementEntry: SessionEntry = { sessionId: "replacement-session", updatedAt: 2 }; + const onCompactionNoticePayload = vi.fn(async () => {}); + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValueOnce(initialEntry).mockReturnValue(replacementEntry); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + await onCompactionNotice?.("end"); + return initialEntry; + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + onCompactionNoticePayload, + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(onCompactionNoticePayload).not.toHaveBeenCalled(); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("cancels preflight immediately when the start notice sees invalidation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const replacementEntry: SessionEntry = { sessionId: "replacement-session", updatedAt: 2 }; + state.shouldNotifyCompaction = true; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.loadEntry.mockReturnValueOnce(initialEntry).mockReturnValue(replacementEntry); + state.preflight.mockImplementation(async ({ onCompactionNotice }) => { + try { + await onCompactionNotice?.("start"); + } catch { + // The memory owner logs notice failures; the abort signal stops its compactor. + } + throw new Error("compaction aborted"); + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, storePath: "/tmp/sessions.json" }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(operation.abortForRestart).toHaveBeenCalledOnce(); + expect(operation.complete).toHaveBeenCalledOnce(); + }); + + it("returns a source-suppression-deliverable preflight failure", async () => { + const operation = createOperation(); + state.admitReply.mockResolvedValue({ status: "owned", operation }); + state.preflight.mockRejectedValue(new Error("preflight failed")); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults(), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { preflightFailurePayload: { text: "preflight failed" } }, + }); + expect(operation.fail).toHaveBeenCalledWith("run_failed", expect.any(Error)); + }); + + it("refreshes send policy before returning a preflight failure", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { sessionId: "queued-session", updatedAt: 1 }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async () => { + state.sendPolicy = "deny"; + throw new Error("preflight failed"); + }); + + const result = await admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }); + + expect(result).toMatchObject({ + kind: "admitted", + turn: { sendPolicy: "deny", preflightFailurePayload: { text: "preflight failed" } }, + }); + }); + + it("restores the item when a failing preflight observes a replacement generation", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "admitted", + updatedAt: 1, + }; + const replacementEntry: SessionEntry = { + sessionId: "replacement-session", + lifecycleRevision: "replacement", + updatedAt: 2, + }; + const sessionStore = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async () => { + sessionStore.main = replacementEntry; + throw new Error("preflight failed"); + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }), + ).rejects.toThrow("Follow-up session generation changed after reply admission"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.buildPreflightFailureText).not.toHaveBeenCalled(); + }); + + it("restores the item when a failing preflight observes in-memory deletion", async () => { + const operation = createOperation(); + const initialEntry: SessionEntry = { + sessionId: "queued-session", + lifecycleRevision: "admitted", + updatedAt: 1, + }; + const sessionStore: Record = { main: initialEntry }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: initialEntry }); + state.preflight.mockImplementation(async () => { + delete sessionStore.main; + throw new Error("preflight failed"); + }); + + await expect( + admitFollowupTurn({ + queued: createRun(), + defaults: createDefaults({ sessionEntry: initialEntry, sessionStore }), + }), + ).rejects.toThrow("Follow-up session generation changed"); + expect(operation.complete).toHaveBeenCalledOnce(); + expect(state.buildPreflightFailureText).not.toHaveBeenCalled(); + }); + + it("uses admitted verbosity when formatting a preflight failure", async () => { + const operation = createOperation(); + const admittedEntry: SessionEntry = { + sessionId: "queued-session", + updatedAt: 2, + verboseLevel: "off", + }; + state.admitReply.mockResolvedValue({ status: "owned", operation, sessionEntry: admittedEntry }); + state.loadEntry.mockReturnValue(admittedEntry); + state.preflight.mockRejectedValue(new Error("preflight failed")); + const queued = createRun(); + queued.run.verboseLevel = "full"; + + await admitFollowupTurn({ + queued, + defaults: createDefaults({ sessionEntry: admittedEntry }), + }); + + expect(state.buildPreflightFailureText).toHaveBeenCalledWith("preflight failed", { + includeDetails: false, + }); + }); +}); diff --git a/src/auto-reply/reply/followup-turn-admission.ts b/src/auto-reply/reply/followup-turn-admission.ts new file mode 100644 index 000000000000..612e68811bdb --- /dev/null +++ b/src/auto-reply/reply/followup-turn-admission.ts @@ -0,0 +1,652 @@ +import crypto from "node:crypto"; +import type { CurrentInboundPromptContext } from "../../agents/embedded-agent-runner/run/params.js"; +import { normalizeChatType } from "../../channels/chat-type.js"; +import type { SessionEntry } from "../../config/sessions.js"; +import { resolveSessionTranscriptPath } from "../../config/sessions/paths.js"; +import { loadSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { TypingMode } from "../../config/types.js"; +import type { OpenClawConfig } from "../../config/types.openclaw.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import { resolveSendPolicy } from "../../sessions/send-policy.js"; +import { sessionDeliveryChannel } from "../../utils/delivery-context.shared.js"; +import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js"; +import type { ReplyPayload } from "../types.js"; +import { resolveRunAfterAutoFallbackPrimaryProbeRecheck } from "./agent-runner-auto-fallback.js"; +import { resolveAdmittedRunSessionFile } from "./agent-runner-core.js"; +import { buildPreflightCompactionFailureText } from "./agent-runner-failure-reply.js"; +import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js"; +import { + resolveQueuedReplyExecutionConfig, + resolveQueuedReplyRuntimeConfig, +} from "./agent-runner-utils.js"; +import { + createCompactionNoticePayload, + shouldNotifyUserAboutCompaction, + type CompactionNoticePhase, +} from "./compaction-notice.js"; +import type { InternalGetReplyOptions } from "./get-reply.types.js"; +import { refreshActiveGoalContext } from "./inbound-meta.js"; +import { + admitFollowupRunLifecycle, + isFollowupRunAborted, + resolveFollowupAbortSignal, + type FollowupRun, +} from "./queue.js"; +import type { ReplyOperation } from "./reply-run-registry.js"; +import { admitReplyTurn } from "./reply-turn-admission.js"; +import type { TypingController } from "./typing.js"; + +export type FollowupRunnerParams = { + opts?: InternalGetReplyOptions; + typing: TypingController; + typingMode: TypingMode; + sessionEntry?: SessionEntry; + sessionStore?: Record; + sessionKey?: string; + storePath?: string; + defaultModel: string; + agentCfgContextTokens?: number; + toolProgressDetail?: "explain" | "raw"; +}; + +type FollowupSessionOwner = + | { + kind: "detached"; + current(): SessionEntry | undefined; + publish(entry: SessionEntry | undefined): void; + adopt(entry: SessionEntry): void; + } + | { + kind: "session"; + key: string; + storePath?: string; + current(): SessionEntry | undefined; + publish(entry: SessionEntry | undefined): void; + adopt(entry: SessionEntry): void; + }; + +type FollowupSessionStoreOwner = FollowupSessionOwner & { + clear(): void; +}; + +export type AdmittedFollowupTurn = { + runId: string; + queued: FollowupRun; + operation: ReplyOperation; + config: OpenClawConfig; + session: FollowupSessionOwner; + sessionStore?: Record; + currentInboundContext?: CurrentInboundPromptContext; + sendPolicy: "allow" | "deny"; + preflightCompactionApplied: boolean; + preflightFailurePayload?: ReplyPayload; + preflightError?: unknown; +}; + +type FollowupAdmissionResult = + | { kind: "admitted"; turn: AdmittedFollowupTurn } + | { kind: "deferred"; reason: "active-run" } + | { + kind: "skipped"; + reason: "aborted" | "lifecycle-invalidated"; + operation?: ReplyOperation; + }; + +class FollowupSessionGenerationInvalidatedError extends Error {} + +function createFollowupSessionOwner(params: { + admittedSessionId: string; + entry?: SessionEntry; + expectedStoreEntry?: SessionEntry; + key?: string; + store?: Record; + storePath?: string; +}): FollowupSessionStoreOwner { + let ownedSessionId = params.admittedSessionId; + let ownedLifecycleRevision = + params.entry?.sessionId === ownedSessionId ? params.entry.lifecycleRevision : undefined; + const matchesGeneration = (entry: SessionEntry | undefined) => + entry?.sessionId === ownedSessionId && entry.lifecycleRevision === ownedLifecycleRevision + ? entry + : undefined; + let currentEntry = matchesGeneration(params.entry); + const current = () => { + const storedEntry = matchesGeneration(params.key ? params.store?.[params.key] : undefined); + if (storedEntry && (!currentEntry || storedEntry.updatedAt >= currentEntry.updatedAt)) { + currentEntry = storedEntry; + } + return currentEntry; + }; + const publish = (entry: SessionEntry | undefined) => { + const nextEntry = matchesGeneration(entry); + if (nextEntry && (!currentEntry || nextEntry.updatedAt >= currentEntry.updatedAt)) { + currentEntry = nextEntry; + } + if (nextEntry && params.key && params.store) { + const storedEntry = params.store[params.key]; + if (!storedEntry && params.expectedStoreEntry) { + return; + } + if ( + !storedEntry || + (matchesGeneration(storedEntry) && nextEntry.updatedAt >= storedEntry.updatedAt) + ) { + params.store[params.key] = nextEntry; + } + } + }; + const clear = () => { + currentEntry = undefined; + if (params.key && params.store && matchesGeneration(params.store[params.key])) { + delete params.store[params.key]; + } + }; + const adopt = (entry: SessionEntry) => { + const storedEntry = params.key ? params.store?.[params.key] : undefined; + const storedMatchesOwnedGeneration = Boolean(matchesGeneration(storedEntry)); + const storedMatchesAdoptedGeneration = Boolean( + storedEntry && + storedEntry.sessionId === entry.sessionId && + storedEntry.lifecycleRevision === entry.lifecycleRevision, + ); + const storedEntryWasDeleted = Boolean( + params.key && params.store && !storedEntry && params.expectedStoreEntry, + ); + if ( + storedEntryWasDeleted || + (storedEntry && !storedMatchesOwnedGeneration && !storedMatchesAdoptedGeneration) + ) { + throw new FollowupSessionGenerationInvalidatedError( + "Follow-up session generation was replaced during admission", + ); + } + const adoptedEntry = + storedMatchesAdoptedGeneration && storedEntry && storedEntry.updatedAt >= entry.updatedAt + ? storedEntry + : entry; + ownedSessionId = adoptedEntry.sessionId; + ownedLifecycleRevision = adoptedEntry.lifecycleRevision; + currentEntry = adoptedEntry; + if ( + params.key && + params.store && + (!storedEntry || storedMatchesOwnedGeneration || adoptedEntry !== storedEntry) + ) { + params.store[params.key] = adoptedEntry; + } + }; + if ( + currentEntry && + params.key && + params.store?.[params.key] && + ((params.store[params.key] === params.expectedStoreEntry && + !matchesGeneration(params.store[params.key])) || + (matchesGeneration(params.store[params.key]) && + currentEntry.updatedAt >= params.store[params.key]!.updatedAt)) + ) { + params.store[params.key] = currentEntry; + } + return params.key + ? { + kind: "session", + key: params.key, + storePath: params.storePath, + current, + clear, + publish, + adopt, + } + : { kind: "detached", current, clear, publish, adopt }; +} + +function resolveFollowupCurrentMessageId(queued: FollowupRun): string | undefined { + return queued.run.inputProvenance?.kind === "internal_system" && + queued.run.inputProvenance.sourceTool === "restart-sentinel" + ? queued.originatingReplyToId + : queued.messageId; +} + +function isSameSessionGeneration( + left: SessionEntry | undefined, + right: SessionEntry | undefined, +): boolean { + return Boolean( + left && + right && + left.sessionId === right.sessionId && + left.lifecycleRevision === right.lifecycleRevision, + ); +} + +function createFollowupSessionStoreView(params: { + key?: string; + owner: FollowupSessionStoreOwner; + store?: Record; +}): Record | undefined { + if (!params.key) { + return params.store; + } + const view = { ...params.store }; + Object.defineProperty(view, params.key, { + configurable: true, + enumerable: true, + get: () => params.owner.current(), + set: (entry: SessionEntry | undefined) => { + if (!entry) { + params.owner.clear(); + return; + } + const current = params.owner.current(); + if (!isSameSessionGeneration(entry, current)) { + params.owner.adopt(entry); + return; + } + params.owner.publish(entry); + }, + }); + return new Proxy(view, { + deleteProperty: (target, key) => { + if (key === params.key) { + // CAS failures invalidate only the owned generation; a concurrent replacement + // remains in the backing store while this view forgets its stale snapshot. + params.owner.clear(); + return true; + } + return Reflect.deleteProperty(target, key); + }, + }); +} + +/** Resolves one queued item into an immutable admitted turn. */ +export async function admitFollowupTurn(params: { + queued: FollowupRun; + defaults: FollowupRunnerParams; + onCompactionNoticePayload?: (payload: ReplyPayload, turn: AdmittedFollowupTurn) => Promise; +}): Promise { + const resolvedConfig = await resolveQueuedReplyExecutionConfig(params.queued.run.config, { + originatingChannel: params.queued.originatingChannel, + messageProvider: params.queued.run.messageProvider, + originatingAccountId: params.queued.originatingAccountId, + agentAccountId: params.queued.run.agentAccountId, + }); + const config = resolveQueuedReplyRuntimeConfig(resolvedConfig); + const replySessionKey = params.queued.run.sessionKey ?? params.defaults.sessionKey; + const initialStoredEntry = replySessionKey + ? params.defaults.sessionStore?.[replySessionKey] + : undefined; + const initialEntry = + initialStoredEntry ?? + (replySessionKey === params.defaults.sessionKey ? params.defaults.sessionEntry : undefined); + let run = { ...params.queued.run, config }; + const admission = await admitReplyTurn({ + sessionId: params.queued.admissionSessionId ?? run.sessionId, + sessionKey: replySessionKey ?? "", + expectedSessionId: initialEntry?.sessionId, + storePath: params.defaults.storePath, + kind: "queued_followup", + resetTriggered: false, + routeThreadId: params.queued.originatingThreadId, + upstreamAbortSignal: resolveFollowupAbortSignal(params.queued), + onReplyAdmissionWaitChange: params.queued.onReplyAdmissionWaitChange, + }); + if (admission.status === "skipped") { + return admission.reason === "active-run" + ? { kind: "deferred", reason: "active-run" } + : { kind: "skipped", reason: admission.reason }; + } + const operation = admission.operation; + operation.retainFailureUntilComplete(); + try { + await admitFollowupRunLifecycle(params.queued); + if (isFollowupRunAborted(params.queued)) { + return { kind: "skipped", reason: "aborted", operation }; + } + + // Queue drains retain the latest live runner closure per key. Keep local dispatcher + // callbacks in that closure so retried non-routable items use the newest transport owner. + await params.defaults.opts?.onQueuedFollowupAdmitted?.(); + if (operation.sessionId !== run.sessionId) { + run = { + ...run, + sessionId: operation.sessionId, + sessionFile: + resolveAdmittedRunSessionFile({ + agentId: run.agentId, + sessionId: operation.sessionId, + storePath: params.defaults.storePath, + }) ?? resolveSessionTranscriptPath(operation.sessionId, run.agentId), + cliSessionBindingFacts: undefined, + autoFallbackPrimaryProbe: undefined, + modelSelectionLocked: false, + }; + } + const admittedEntry = replySessionKey + ? params.defaults.storePath + ? loadSessionEntry({ storePath: params.defaults.storePath, sessionKey: replySessionKey }) + : params.defaults.sessionStore?.[replySessionKey] + : undefined; + const expectedPersistedEntry = + admission.sessionEntry?.sessionId === operation.sessionId + ? admission.sessionEntry + : initialEntry?.sessionId === operation.sessionId + ? initialEntry + : undefined; + const assertPersistedGeneration = (entry: SessionEntry | undefined) => { + const matchesExpectedGeneration = isSameSessionGeneration(entry, expectedPersistedEntry); + const shouldValidateGeneration = + Boolean(params.defaults.storePath) || entry !== initialStoredEntry; + if ( + shouldValidateGeneration && + ((expectedPersistedEntry && !matchesExpectedGeneration) || + (!expectedPersistedEntry && entry && entry.sessionId !== operation.sessionId)) + ) { + throw new FollowupSessionGenerationInvalidatedError( + "Follow-up session generation changed after reply admission", + ); + } + }; + assertPersistedGeneration(admittedEntry); + const admissionEntry = + admission.sessionEntry?.sessionId === operation.sessionId + ? admission.sessionEntry + : undefined; + const reloadedEntry = + admittedEntry?.sessionId === operation.sessionId ? admittedEntry : undefined; + const freshestMatchingEntry = + reloadedEntry && admissionEntry + ? reloadedEntry.updatedAt >= admissionEntry.updatedAt + ? reloadedEntry + : admissionEntry + : (reloadedEntry ?? admissionEntry); + let activeEntry = + freshestMatchingEntry ?? + (admittedEntry === undefined && initialEntry?.sessionId === operation.sessionId + ? initialEntry + : undefined); + const lifecycleRevisionChanged = + operation.sessionId === params.queued.run.sessionId && + activeEntry?.sessionId === operation.sessionId && + activeEntry.lifecycleRevision !== + (initialEntry?.sessionId === operation.sessionId + ? initialEntry.lifecycleRevision + : undefined); + if (activeEntry?.sessionId === operation.sessionId) { + run = { + ...run, + sessionFile: + resolveAdmittedRunSessionFile({ + agentId: run.agentId, + sessionId: operation.sessionId, + sessionFile: activeEntry.sessionFile, + storePath: params.defaults.storePath, + }) ?? run.sessionFile, + modelSelectionLocked: activeEntry.modelSelectionLocked === true, + ...(lifecycleRevisionChanged + ? { + cliSessionBindingFacts: undefined, + autoFallbackPrimaryProbe: undefined, + } + : {}), + }; + } + run = resolveRunAfterAutoFallbackPrimaryProbeRecheck({ + run, + entry: activeEntry, + sessionKey: replySessionKey, + }); + const queued: FollowupRun = { ...params.queued, run }; + const session = createFollowupSessionOwner({ + admittedSessionId: operation.sessionId, + entry: activeEntry, + expectedStoreEntry: initialStoredEntry, + key: replySessionKey, + store: params.defaults.sessionStore, + storePath: params.defaults.storePath, + }); + const sessionStore = createFollowupSessionStoreView({ + key: replySessionKey, + owner: session, + store: params.defaults.sessionStore, + }); + let sendPolicy = resolveSendPolicy({ + cfg: config, + entry: activeEntry, + sessionKey: run.runtimePolicySessionKey ?? replySessionKey, + channel: + queued.originatingChannel ?? run.messageProvider ?? sessionDeliveryChannel(activeEntry), + chatType: normalizeChatType( + queued.originatingChatType ?? run.chatType ?? activeEntry?.chatType, + ), + }); + let currentInboundContext = + params.defaults.opts?.isHeartbeat === true + ? queued.currentInboundContext + : refreshActiveGoalContext(queued.currentInboundContext, activeEntry); + // Preallocate the one lifecycle identity passed as opts.runId; canonical + // execution owns registration and cleanup under this same id. + const turn: AdmittedFollowupTurn = { + runId: crypto.randomUUID(), + queued: { ...queued, currentInboundContext }, + operation, + config, + session, + sessionStore, + currentInboundContext, + sendPolicy, + preflightCompactionApplied: false, + }; + const refreshTurnSessionState = (entry: SessionEntry | undefined) => { + sendPolicy = resolveSendPolicy({ + cfg: config, + entry, + sessionKey: turn.queued.run.runtimePolicySessionKey ?? replySessionKey, + channel: + turn.queued.originatingChannel ?? + turn.queued.run.messageProvider ?? + sessionDeliveryChannel(entry), + chatType: normalizeChatType( + turn.queued.originatingChatType ?? turn.queued.run.chatType ?? entry?.chatType, + ), + }); + currentInboundContext = + params.defaults.opts?.isHeartbeat === true + ? params.queued.currentInboundContext + : refreshActiveGoalContext(params.queued.currentInboundContext, entry); + turn.sendPolicy = sendPolicy; + turn.currentInboundContext = currentInboundContext; + turn.queued = { ...turn.queued, currentInboundContext }; + }; + const synchronizeTurnGeneration = ( + entry: SessionEntry | undefined, + previousEntry: SessionEntry | undefined, + ) => { + const generationRotated = Boolean(entry && !isSameSessionGeneration(entry, previousEntry)); + if (entry && generationRotated) { + operation.updateSessionId(entry.sessionId); + turn.queued = { + ...turn.queued, + run: { + ...turn.queued.run, + sessionId: entry.sessionId, + sessionFile: + resolveAdmittedRunSessionFile({ + agentId: turn.queued.run.agentId, + sessionId: entry.sessionId, + sessionFile: entry.sessionFile, + storePath: params.defaults.storePath, + }) ?? resolveSessionTranscriptPath(entry.sessionId, turn.queued.run.agentId), + cliSessionBindingFacts: undefined, + autoFallbackPrimaryProbe: undefined, + modelSelectionLocked: entry.modelSelectionLocked === true, + }, + }; + } + return generationRotated; + }; + const previousCompactionCount = activeEntry?.compactionCount ?? 0; + let pendingTerminalCompactionNotice: Exclude | undefined; + let compactionNoticeGenerationInvalidated = false; + const notifyPreflightCompaction = + sendPolicy === "allow" && + queued.currentInboundEventKind !== "room_event" && + shouldNotifyUserAboutCompaction(config) + ? async (phase: CompactionNoticePhase) => { + if (phase !== "start") { + pendingTerminalCompactionNotice = phase; + return; + } + const noticeEntry = + replySessionKey && params.defaults.storePath + ? loadSessionEntry({ + storePath: params.defaults.storePath, + sessionKey: replySessionKey, + }) + : replySessionKey && params.defaults.sessionStore + ? params.defaults.sessionStore[replySessionKey] + : session.current(); + try { + assertPersistedGeneration(noticeEntry); + } catch (error) { + if (error instanceof FollowupSessionGenerationInvalidatedError) { + compactionNoticeGenerationInvalidated = true; + operation.abortForRestart(); + throw error; + } + throw error; + } + const noticeSendPolicy = resolveSendPolicy({ + cfg: config, + entry: noticeEntry, + sessionKey: turn.queued.run.runtimePolicySessionKey ?? replySessionKey, + channel: + turn.queued.originatingChannel ?? + turn.queued.run.messageProvider ?? + sessionDeliveryChannel(noticeEntry), + chatType: normalizeChatType( + turn.queued.originatingChatType ?? + turn.queued.run.chatType ?? + noticeEntry?.chatType, + ), + }); + if (noticeSendPolicy === "deny") { + return; + } + await params.onCompactionNoticePayload?.( + createCompactionNoticePayload({ + phase, + currentMessageId: resolveFollowupCurrentMessageId(queued), + }), + turn, + ); + } + : undefined; + const preflightEntry = session.current(); + try { + activeEntry = await runPreflightCompactionIfNeeded({ + cfg: config, + followupRun: turn.queued, + promptForEstimate: turn.queued.prompt, + defaultModel: params.defaults.defaultModel, + agentCfgContextTokens: params.defaults.agentCfgContextTokens, + sessionEntry: activeEntry, + sessionStore, + sessionKey: replySessionKey, + storePath: params.defaults.storePath, + isHeartbeat: params.defaults.opts?.isHeartbeat === true, + replyOperation: operation, + onCompactionNotice: notifyPreflightCompaction, + }); + if (compactionNoticeGenerationInvalidated) { + throw new FollowupSessionGenerationInvalidatedError( + "Follow-up session generation changed during preflight notice delivery", + ); + } + if (replySessionKey && params.defaults.storePath) { + const persistedEntry = loadSessionEntry({ + storePath: params.defaults.storePath, + sessionKey: replySessionKey, + }); + if ( + (!persistedEntry && preflightEntry) || + (persistedEntry && + !isSameSessionGeneration(persistedEntry, preflightEntry) && + !isSameSessionGeneration(persistedEntry, activeEntry)) + ) { + throw new FollowupSessionGenerationInvalidatedError( + "Follow-up session generation changed during preflight", + ); + } + if ( + persistedEntry && + (!activeEntry || + (isSameSessionGeneration(persistedEntry, activeEntry) && + persistedEntry.updatedAt >= activeEntry.updatedAt)) + ) { + activeEntry = persistedEntry; + } + } + if (activeEntry) { + session.adopt(activeEntry); + activeEntry = session.current() ?? activeEntry; + } + const generationRotated = synchronizeTurnGeneration(activeEntry, preflightEntry); + refreshTurnSessionState(activeEntry); + turn.preflightCompactionApplied = + generationRotated || (activeEntry?.compactionCount ?? 0) > previousCompactionCount; + } catch (error) { + const failureEntry = + replySessionKey && params.defaults.storePath + ? loadSessionEntry({ + storePath: params.defaults.storePath, + sessionKey: replySessionKey, + }) + : replySessionKey && params.defaults.sessionStore + ? params.defaults.sessionStore[replySessionKey] + : session.current(); + if (!isSameSessionGeneration(failureEntry, session.current())) { + assertPersistedGeneration(failureEntry); + } + if (failureEntry) { + session.adopt(failureEntry); + activeEntry = session.current() ?? failureEntry; + } + synchronizeTurnGeneration(activeEntry, preflightEntry); + refreshTurnSessionState(activeEntry); + if (compactionNoticeGenerationInvalidated) { + throw new FollowupSessionGenerationInvalidatedError( + "Follow-up session generation changed during preflight notice delivery", + ); + } + if (error instanceof FollowupSessionGenerationInvalidatedError) { + throw error; + } + operation.fail("run_failed", error); + const admittedVerboseLevel = session.current()?.verboseLevel ?? turn.queued.run.verboseLevel; + const text = buildPreflightCompactionFailureText(formatErrorMessage(error), { + includeDetails: admittedVerboseLevel === "on" || admittedVerboseLevel === "full", + }); + if (!text) { + turn.preflightError = error; + } else { + turn.preflightFailurePayload = markReplyPayloadForSourceSuppressionDelivery({ text }); + } + } + if ( + pendingTerminalCompactionNotice && + turn.sendPolicy === "allow" && + turn.queued.currentInboundEventKind !== "room_event" + ) { + await params.onCompactionNoticePayload?.( + createCompactionNoticePayload({ + phase: pendingTerminalCompactionNotice, + currentMessageId: resolveFollowupCurrentMessageId(turn.queued), + }), + turn, + ); + } + return { kind: "admitted", turn }; + } catch (error) { + operation.complete(); + throw error instanceof Error ? error : new Error(formatErrorMessage(error)); + } +} diff --git a/src/auto-reply/reply/followup-turn-execution.test.ts b/src/auto-reply/reply/followup-turn-execution.test.ts new file mode 100644 index 000000000000..c5a098178a4a --- /dev/null +++ b/src/auto-reply/reply/followup-turn-execution.test.ts @@ -0,0 +1,633 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AgentTurnParams } from "./agent-runner-execution.types.js"; +import type { AdmittedFollowupTurn } from "./followup-turn-admission.js"; + +const state = vi.hoisted(() => ({ + execute: vi.fn(), + loadEntryReadOnly: vi.fn(), + reset: vi.fn(), +})); + +vi.mock("./agent-runner-execution.js", () => ({ + executeAgentTurn: (...args: unknown[]) => state.execute(...args), +})); + +vi.mock("./agent-runner-session-reset.js", () => ({ + resetReplyRunSession: (...args: unknown[]) => state.reset(...args), +})); + +vi.mock("../../config/sessions/session-accessor.js", () => ({ + loadSessionEntryReadOnly: (...args: unknown[]) => state.loadEntryReadOnly(...args), +})); + +const { executeFollowupTurn } = await import("./followup-turn-execution.js"); + +function createTypingController() { + return { + onReplyStart: vi.fn(async () => {}), + startTypingLoop: vi.fn(async () => {}), + startTypingOnText: vi.fn(async () => {}), + refreshTypingTtl: vi.fn(), + isActive: vi.fn(() => false), + markRunComplete: vi.fn(), + markDispatchIdle: vi.fn(), + cleanup: vi.fn(), + }; +} + +function createTurn(overrides: Partial = {}): AdmittedFollowupTurn { + return { + runId: "run-1", + queued: { + prompt: "queued prompt", + transcriptPrompt: "queued transcript", + enqueuedAt: 1, + messageId: "message-1", + originatingChannel: "discord", + originatingTo: "channel:C1", + originatingThreadId: "thread-1", + originatingAccountId: "acct-1", + originatingChatType: "group", + media: [{ kind: "audio", contentType: "audio/ogg" }], + run: { + agentId: "agent", + agentDir: "/tmp/agent", + sessionId: "session", + sessionKey: "main", + sessionFile: "/tmp/session.jsonl", + workspaceDir: "/tmp", + config: {}, + provider: "anthropic", + model: "claude", + messageProvider: "slack", + senderId: "user-1", + timeoutMs: 1_000, + blockReplyBreak: "message_end", + }, + }, + operation: { abortSignal: new AbortController().signal } as AdmittedFollowupTurn["operation"], + config: {}, + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "on" }), + publish: () => undefined, + adopt: () => undefined, + }, + sendPolicy: "allow", + preflightCompactionApplied: false, + ...overrides, + }; +} + +beforeEach(() => { + vi.clearAllMocks(); + state.loadEntryReadOnly.mockReturnValue(undefined); + state.execute.mockResolvedValue({ + runId: "run-1", + outcome: { kind: "rejected", payload: { text: "done" } }, + }); +}); + +describe("executeFollowupTurn", () => { + it("normalizes queued route facts into the canonical execution call", async () => { + const turn = createTurn(); + const typing = createTypingController(); + const onExecutionStarted = vi.fn(); + const onAgentRunStart = vi.fn(); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + params.opts?.onAgentRunStart?.("run-1"); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + await executeFollowupTurn({ + turn, + defaults: { + typing, + typingMode: "instant", + defaultModel: "claude", + opts: { onAgentRunStart }, + }, + onExecutionStarted, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + const call = state.execute.mock.calls[0]?.[0] as AgentTurnParams; + expect(call).toMatchObject({ + commandBody: "queued prompt", + transcriptCommandBody: "queued transcript", + followupRun: turn.queued, + blockReplyPipeline: null, + blockStreamingEnabled: false, + sessionKey: "main", + }); + expect(call.opts?.runId).toBe("run-1"); + expect(call.sessionCtx).toMatchObject({ + Provider: "slack", + Surface: "discord", + SessionKey: "main", + RuntimePolicySessionKey: "main", + OriginatingTo: "channel:C1", + MessageThreadId: "thread-1", + MessageSid: "message-1", + SenderId: "user-1", + }); + expect(call.sessionCtx.media).toEqual([{ kind: "audio", contentType: "audio/ogg" }]); + expect(onExecutionStarted).toHaveBeenCalledOnce(); + expect(onAgentRunStart).toHaveBeenCalledWith("run-1"); + }); + + it("ignores verbosity loaded from a replacement session generation", async () => { + const currentEntry = { + sessionId: "session", + lifecycleRevision: "owned", + updatedAt: 1, + verboseLevel: "off" as const, + }; + const turn = createTurn({ + session: { + kind: "session", + key: "main", + storePath: "/tmp/sessions.json", + current: () => currentEntry, + publish: () => undefined, + adopt: () => undefined, + }, + }); + state.loadEntryReadOnly.mockReturnValue({ + ...currentEntry, + lifecycleRevision: "replacement", + verboseLevel: "full", + }); + + await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + const call = state.execute.mock.calls[0]?.[0] as AgentTurnParams; + expect(call.resolvedVerboseLevel).toBe("off"); + }); + + it("ignores older verbosity from the admitted session generation", async () => { + const currentEntry = { + sessionId: "session", + lifecycleRevision: "owned", + updatedAt: 2, + verboseLevel: "off" as const, + }; + const turn = createTurn({ + session: { + kind: "session", + key: "main", + storePath: "/tmp/sessions.json", + current: () => currentEntry, + publish: () => undefined, + adopt: () => undefined, + }, + }); + state.loadEntryReadOnly.mockReturnValue({ + ...currentEntry, + updatedAt: 1, + verboseLevel: "full", + }); + + await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + const call = state.execute.mock.calls[0]?.[0] as AgentTurnParams; + expect(call.resolvedVerboseLevel).toBe("off"); + }); + + it("keeps room-event progress, tool summaries, and typing silent", async () => { + const turn = createTurn({ + queued: { ...createTurn().queued, currentInboundEventKind: "room_event" }, + }); + const typing = createTypingController(); + const onToolResult = vi.fn(async () => {}); + const onCompactionStart = vi.fn(async () => {}); + const onCompactionEnd = vi.fn(async () => {}); + const onReasoningEnd = vi.fn(async () => {}); + const onNarrationUpdate = vi.fn(async () => {}); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.typingSignals.signalRunStart(); + await params.opts?.onToolResult?.({ text: "private progress" }); + await params.opts?.onCompactionStart?.(); + await params.opts?.onCompactionEnd?.(); + await params.opts?.onReasoningEnd?.(); + await params.opts?.onNarrationUpdate?.({ text: "private narration" }); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + const result = await executeFollowupTurn({ + turn, + defaults: { + typing, + typingMode: "instant", + defaultModel: "claude", + opts: { onCompactionStart, onCompactionEnd, onReasoningEnd, onNarrationUpdate }, + }, + onToolResult, + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(typing.startTypingLoop).not.toHaveBeenCalled(); + expect(typing.startTypingOnText).not.toHaveBeenCalled(); + expect(onToolResult).not.toHaveBeenCalled(); + expect(onCompactionStart).not.toHaveBeenCalled(); + expect(onCompactionEnd).not.toHaveBeenCalled(); + expect(onReasoningEnd).not.toHaveBeenCalled(); + expect(onNarrationUpdate).not.toHaveBeenCalled(); + }); + + it("allows explicitly opted-in tool lifecycle while ordinary progress is hidden", async () => { + const onToolStart = vi.fn(async () => {}); + const turn = createTurn({ + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "off" }), + publish: () => undefined, + adopt: () => undefined, + }, + }); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onToolStart?.({ name: "read", phase: "start" }); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + const result = await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { onToolStart, allowToolLifecycleWhenProgressHidden: true }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onToolStart).toHaveBeenCalledOnce(); + }); + + it("preserves plan updates when tool-result verbosity is off", async () => { + const onPlanUpdate = vi.fn(async () => undefined); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onPlanUpdate?.({ title: "quiet plan" }); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + const result = await executeFollowupTurn({ + turn: createTurn({ + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "off" }), + publish: () => undefined, + adopt: () => undefined, + }, + }), + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { onPlanUpdate }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onPlanUpdate).toHaveBeenCalledWith({ title: "quiet plan" }); + }); + + it("tracks a visible failed item before suppressing duplicate default warnings", async () => { + const onItemEvent = vi.fn(async () => undefined); + let warningSuppressed: boolean | undefined; + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onItemEvent?.({ phase: "end", status: "failed" }); + warningSuppressed = params.opts?.shouldSuppressToolErrorWarnings?.(); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + const result = await executeFollowupTurn({ + turn: createTurn(), + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { onItemEvent }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onItemEvent).toHaveBeenCalledOnce(); + expect(warningSuppressed).toBe(true); + }); + + it("tracks a full-verbosity failed command before suppressing duplicate warnings", async () => { + const onCommandOutput = vi.fn(async () => undefined); + let warningSuppressed: boolean | undefined; + const turn = createTurn({ + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "full" }), + publish: () => undefined, + adopt: () => undefined, + }, + }); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onCommandOutput?.({ status: "failed", exitCode: 1 }); + warningSuppressed = params.opts?.shouldSuppressToolErrorWarnings?.(); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + const result = await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { onCommandOutput }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onCommandOutput).toHaveBeenCalledOnce(); + expect(warningSuppressed).toBe(true); + }); + + it("does not suppress warnings for hidden verbose-off tool errors", async () => { + const turn = createTurn({ + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "off" }), + publish: () => undefined, + adopt: () => undefined, + }, + }); + turn.queued.run.sourceReplyDeliveryMode = "message_tool_only"; + let warningSuppressed: boolean | undefined; + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onToolResult?.({ text: "hidden failure", isError: true }); + warningSuppressed = params.opts?.shouldSuppressToolErrorWarnings?.(); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + const onToolResult = vi.fn(async () => {}); + + const result = await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + }, + onToolResult, + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onToolResult).not.toHaveBeenCalled(); + expect(warningSuppressed).toBe(false); + }); + + it("suppresses duplicate warnings for delivered verbose-off tool errors", async () => { + const turn = createTurn({ + session: { + kind: "session", + key: "main", + current: () => ({ sessionId: "session", updatedAt: 1, verboseLevel: "off" }), + publish: () => undefined, + adopt: () => undefined, + }, + }); + let warningSuppressed: boolean | undefined; + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.opts?.onToolResult?.({ text: "visible failure", isError: true }); + warningSuppressed = params.opts?.shouldSuppressToolErrorWarnings?.(); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + const onToolResult = vi.fn(async () => {}); + + const result = await executeFollowupTurn({ + turn, + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + }, + onToolResult, + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await result.progress.drain(); + + expect(onToolResult).toHaveBeenCalledWith( + { text: "visible failure", isError: true }, + { runId: "run-1" }, + ); + expect(warningSuppressed).toBe(true); + }); + + it("drains detached progress before the caller can project a final", async () => { + const order: string[] = []; + let releaseProgress!: () => void; + const progressBarrier = new Promise((resolve) => { + releaseProgress = resolve; + }); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + void params.opts?.onItemEvent?.({ progressText: "working" }); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + const result = await executeFollowupTurn({ + turn: createTurn(), + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { + onItemEvent: async () => { + await progressBarrier; + order.push("progress"); + }, + }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + const drain = result.progress.drain().then(() => order.push("drained")); + await Promise.resolve(); + expect(order).toEqual([]); + releaseProgress(); + await drain; + expect(order).toEqual(["progress", "drained"]); + }); + + it("preserves detached progress delivery failures for the drain", async () => { + const failure = new Error("progress delivery failed"); + let detachedProgress!: Promise; + state.execute.mockImplementation(async (params: AgentTurnParams) => { + detachedProgress = Promise.resolve(params.opts?.onItemEvent?.({ progressText: "working" })); + void detachedProgress.catch(() => undefined); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + const result = await executeFollowupTurn({ + turn: createTurn(), + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { + onItemEvent: async () => { + throw failure; + }, + }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + await expect(detachedProgress).resolves.toBeUndefined(); + await expect(result.progress.drain()).rejects.toBe(failure); + }); + + it("preserves numeric thread ids during canonical role-ordering recovery", async () => { + const turn = createTurn({ + queued: { ...createTurn().queued, originatingThreadId: 42 }, + }); + state.reset.mockResolvedValue(true); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.resetSessionAfterRoleOrderingConflict("invalid history"); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + await executeFollowupTurn({ + turn, + defaults: { typing: createTypingController(), typingMode: "never", defaultModel: "claude" }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + expect(state.reset).toHaveBeenCalledWith(expect.objectContaining({ messageThreadId: "42" })); + }); + + it("updates the reply operation after role-ordering recovery rotates the session", async () => { + const updateSessionId = vi.fn(); + const turn = createTurn({ + operation: { + abortSignal: new AbortController().signal, + updateSessionId, + } as unknown as AdmittedFollowupTurn["operation"], + }); + state.reset.mockImplementation(async (params) => { + params.onActiveSessionEntry({ sessionId: "reset-session", updatedAt: 2 }); + return true; + }); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + await params.resetSessionAfterRoleOrderingConflict("invalid history"); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + + await executeFollowupTurn({ + turn, + defaults: { typing: createTypingController(), typingMode: "never", defaultModel: "claude" }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + expect(updateSessionId).toHaveBeenCalledWith("reset-session"); + }); + + it("drains detached progress before propagating execution failure", async () => { + const order: string[] = []; + let releaseProgress!: () => void; + const progressBarrier = new Promise((resolve) => { + releaseProgress = resolve; + }); + const failure = new Error("execution failed"); + state.execute.mockImplementation(async (params: AgentTurnParams) => { + void params.opts?.onItemEvent?.({ progressText: "working" }); + throw failure; + }); + const pending = executeFollowupTurn({ + turn: createTurn(), + defaults: { + typing: createTypingController(), + typingMode: "never", + defaultModel: "claude", + opts: { + onItemEvent: async () => { + await progressBarrier; + order.push("progress"); + }, + }, + }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + await Promise.resolve(); + expect(order).toEqual([]); + releaseProgress(); + await expect(pending).rejects.toBe(failure); + expect(order).toEqual(["progress"]); + }); + + it("waits for every pending task before propagating a drain failure", async () => { + const failure = new Error("tool task failed"); + let releaseSlowTask!: () => void; + const slowBarrier = new Promise((resolve) => { + releaseSlowTask = resolve; + }); + const order: string[] = []; + state.execute.mockImplementation(async (params: AgentTurnParams) => { + const failedTask = Promise.reject(failure).finally(() => { + params.pendingToolTasks.delete(failedTask); + }); + const slowTask = slowBarrier + .then(() => { + order.push("slow-finished"); + }) + .finally(() => { + params.pendingToolTasks.delete(slowTask); + }); + params.pendingToolTasks.add(failedTask); + params.pendingToolTasks.add(slowTask); + return { runId: "run-1", outcome: { kind: "rejected", payload: { text: "done" } } }; + }); + const result = await executeFollowupTurn({ + turn: createTurn(), + defaults: { typing: createTypingController(), typingMode: "never", defaultModel: "claude" }, + onToolResult: vi.fn(async () => {}), + onCompactionNoticePayload: vi.fn(async () => {}), + }); + + const drain = result.progress.drain(); + await Promise.resolve(); + releaseSlowTask(); + await expect(drain).rejects.toBe(failure); + expect(order).toEqual(["slow-finished"]); + }); +}); diff --git a/src/auto-reply/reply/followup-turn-execution.ts b/src/auto-reply/reply/followup-turn-execution.ts new file mode 100644 index 000000000000..6fef8cc73520 --- /dev/null +++ b/src/auto-reply/reply/followup-turn-execution.ts @@ -0,0 +1,371 @@ +import { loadSessionEntryReadOnly } from "../../config/sessions/session-accessor.js"; +import { formatErrorMessage } from "../../infra/errors.js"; +import type { TemplateContext } from "../templating.js"; +import type { VerboseLevel } from "../thinking.js"; +import type { ReplyPayload } from "../types.js"; +import { executeAgentTurn } from "./agent-runner-execution.js"; +import type { AgentTurnExecutionResult } from "./agent-runner-execution.types.js"; +import { resetReplyRunSession } from "./agent-runner-session-reset.js"; +import type { AdmittedFollowupTurn, FollowupRunnerParams } from "./followup-turn-admission.js"; +import type { InternalGetReplyOptions } from "./get-reply.types.js"; +import { createTypingSignaler, type TypingSignaler } from "./typing-mode.js"; + +export type FollowupExecutionResult = { + execution: AgentTurnExecutionResult; + runStartedAt: number; + sessionCtx: TemplateContext; + pendingToolTasks: Set>; + progress: { + drain(): Promise; + visibleToolErrorObserved(): boolean; + }; +}; + +function buildFollowupTemplateContext(turn: AdmittedFollowupTurn): TemplateContext { + const queued = turn.queued; + const run = queued.run; + const surface = queued.originatingChannel ?? run.messageProvider; + const sessionKey = turn.session.kind === "session" ? turn.session.key : run.sessionKey; + const currentMessageId = + run.inputProvenance?.kind === "internal_system" && + run.inputProvenance.sourceTool === "restart-sentinel" + ? queued.originatingReplyToId + : queued.messageId; + return { + Provider: run.messageProvider, + Surface: surface, + OriginatingChannel: queued.originatingChannel, + OriginatingTo: queued.originatingTo, + To: queued.originatingTo, + AccountId: queued.originatingAccountId ?? run.agentAccountId, + ChatType: queued.originatingChatType ?? run.chatType, + SessionKey: sessionKey, + RuntimePolicySessionKey: run.runtimePolicySessionKey ?? sessionKey, + MessageSid: currentMessageId, + MessageSidFull: currentMessageId, + MessageThreadId: queued.originatingThreadId, + ReplyToId: queued.originatingReplyToId, + SenderId: run.senderId, + SenderName: run.senderName, + SenderUsername: run.senderUsername, + SenderE164: run.senderE164, + GroupChannel: run.groupChannel, + GroupSpace: run.groupSpace, + InputProvenance: run.inputProvenance, + InboundEventKind: queued.currentInboundEventKind, + media: queued.media, + } as TemplateContext; +} + +/** Adapts an admitted queued turn to the canonical agent execution owner. */ +export async function executeFollowupTurn(params: { + turn: AdmittedFollowupTurn; + defaults: FollowupRunnerParams; + onExecutionStarted?: () => void; + onToolResult: (payload: ReplyPayload, execution: { runId: string }) => Promise; + onCompactionNoticePayload: (payload: ReplyPayload, execution: { runId: string }) => Promise; +}): Promise { + const { turn, defaults } = params; + const roomEvent = turn.queued.currentInboundEventKind === "room_event"; + const progressAllowed = () => turn.sendPolicy === "allow" && !roomEvent; + const currentVerboseLevel = (): VerboseLevel => { + const session = turn.session; + if (session.kind === "session" && session.storePath) { + try { + const loadedEntry = loadSessionEntryReadOnly({ + storePath: session.storePath, + sessionKey: session.key, + }); + const ownedEntry = session.current(); + const loadedGenerationMatches = + loadedEntry !== undefined && + ownedEntry !== undefined && + loadedEntry.sessionId === ownedEntry.sessionId && + loadedEntry.lifecycleRevision === ownedEntry.lifecycleRevision && + loadedEntry.updatedAt >= ownedEntry.updatedAt; + if (loadedGenerationMatches) { + const level = loadedEntry.verboseLevel; + if (level === "off" || level === "on" || level === "full") { + return level; + } + } + } catch { + // A queued turn keeps its admitted snapshot when a read races store maintenance. + } + } + const level = session.current()?.verboseLevel ?? turn.queued.run.verboseLevel; + return level === "on" || level === "full" ? level : "off"; + }; + const shouldEmitToolResult = () => + progressAllowed() && (currentVerboseLevel() === "on" || currentVerboseLevel() === "full"); + const shouldEmitToolOutput = () => progressAllowed() && currentVerboseLevel() === "full"; + const shouldEmitToolLifecycle = () => + progressAllowed() && + (shouldEmitToolResult() || defaults.opts?.allowToolLifecycleWhenProgressHidden === true); + let visibleToolError = false; + let progressChain: Promise = Promise.resolve(); + let pendingProgressTaskFailure: unknown; + const pendingProgressTasks = new Set>(); + const enqueueProgress = (deliver: () => Promise | void): Promise => { + const deliveryTask = progressChain.then(deliver); + progressChain = deliveryTask.catch(() => undefined); + const observedTask = deliveryTask.catch((error: unknown) => { + pendingProgressTaskFailure ??= error; + throw error; + }); + const trackedTask = observedTask.finally(() => pendingProgressTasks.delete(trackedTask)); + void trackedTask.catch(() => undefined); + pendingProgressTasks.add(trackedTask); + return progressChain; + }; + const wrap = (callback: ((value: T) => unknown) | undefined, allowed = progressAllowed) => + callback + ? (value: T) => + enqueueProgress(async () => { + if (allowed()) { + await callback(value); + } + }) + : undefined; + const baseTypingSignals = createTypingSignaler({ + typing: defaults.typing, + mode: progressAllowed() ? defaults.typingMode : "never", + isHeartbeat: defaults.opts?.isHeartbeat === true, + }); + const typingSignals: TypingSignaler = { + ...baseTypingSignals, + signalRunStart: () => enqueueProgress(baseTypingSignals.signalRunStart), + signalMessageStart: () => enqueueProgress(baseTypingSignals.signalMessageStart), + signalTextDelta: (text) => enqueueProgress(() => baseTypingSignals.signalTextDelta(text)), + signalReasoningDelta: () => enqueueProgress(baseTypingSignals.signalReasoningDelta), + signalToolStart: () => enqueueProgress(baseTypingSignals.signalToolStart), + signalExecutionActivity: () => + enqueueProgress( + baseTypingSignals.signalExecutionActivity ?? baseTypingSignals.signalRunStart, + ), + }; + const sourceOpts = defaults.opts; + const progressOpts: InternalGetReplyOptions = { + ...sourceOpts, + runId: turn.runId, + onAgentRunStart: (runId) => { + params.onExecutionStarted?.(); + sourceOpts?.onAgentRunStart?.(runId); + }, + onBlockReply: undefined, + onPartialReply: undefined, + onAssistantMessageStart: undefined, + onToolStart: wrap(sourceOpts?.onToolStart, shouldEmitToolLifecycle), + onCommandOutput: sourceOpts?.onCommandOutput + ? (output) => + enqueueProgress(async () => { + if (!shouldEmitToolResult()) { + return; + } + const visible = (await sourceOpts.onCommandOutput?.(output)) !== false; + if ( + visible && + (output.status === "failed" || + output.status === "error" || + (typeof output.exitCode === "number" && output.exitCode !== 0)) + ) { + visibleToolError = true; + } + }) + : undefined, + onItemEvent: sourceOpts?.onItemEvent + ? (item) => + enqueueProgress(async () => { + if (!shouldEmitToolResult()) { + return; + } + const visible = (await sourceOpts.onItemEvent?.(item)) !== false; + if ( + visible && + (item.phase === "error" || item.status === "failed" || item.status === "error") + ) { + visibleToolError = true; + } + }) + : undefined, + onNarrationUpdate: wrap(sourceOpts?.onNarrationUpdate), + onPlanUpdate: wrap(sourceOpts?.onPlanUpdate), + onApprovalEvent: wrap(sourceOpts?.onApprovalEvent, shouldEmitToolResult), + onPatchSummary: wrap(sourceOpts?.onPatchSummary, shouldEmitToolResult), + onCompactionStart: sourceOpts?.onCompactionStart + ? () => + enqueueProgress(() => (progressAllowed() ? sourceOpts.onCompactionStart?.() : undefined)) + : undefined, + onCompactionEnd: sourceOpts?.onCompactionEnd + ? () => + enqueueProgress(() => (progressAllowed() ? sourceOpts.onCompactionEnd?.() : undefined)) + : undefined, + onReasoningStream: wrap(sourceOpts?.onReasoningStream), + onReasoningProgress: wrap(sourceOpts?.onReasoningProgress), + onReasoningEnd: sourceOpts?.onReasoningEnd + ? () => enqueueProgress(() => (progressAllowed() ? sourceOpts.onReasoningEnd?.() : undefined)) + : undefined, + shouldSuppressToolErrorWarnings: () => { + const explicit = sourceOpts?.suppressToolErrorWarnings; + if (explicit !== undefined) { + return explicit; + } + if (visibleToolError) { + return true; + } + if (!shouldEmitToolResult()) { + return false; + } + return undefined; + }, + onToolResult: async (payload) => { + await enqueueProgress(async () => { + if (!progressAllowed()) { + return; + } + const toolResultProgressVisible = shouldEmitToolResult(); + if ( + turn.queued.run.sourceReplyDeliveryMode === "message_tool_only" && + !toolResultProgressVisible + ) { + return; + } + await params.onToolResult(payload, { runId: turn.runId }); + if (payload.isError === true) { + visibleToolError = true; + } + }); + }, + }; + let pendingToolTaskFailure: unknown; + const pendingToolTaskWatchers = new Set>(); + const pendingToolTasks = new (class extends Set> { + override add(task: Promise): this { + const observedTask = task.catch((error: unknown) => { + pendingToolTaskFailure ??= error; + throw error; + }); + const watcher = observedTask.finally(() => pendingToolTaskWatchers.delete(watcher)); + void watcher.catch(() => undefined); + pendingToolTaskWatchers.add(watcher); + return super.add(task); + } + })(); + const sessionCtx = buildFollowupTemplateContext(turn); + if (turn.preflightError) { + throw turn.preflightError instanceof Error + ? turn.preflightError + : new Error(formatErrorMessage(turn.preflightError)); + } + let execution: AgentTurnExecutionResult; + const runStartedAt = Date.now(); + if (turn.preflightFailurePayload) { + execution = { + runId: turn.runId, + outcome: { kind: "rejected", payload: turn.preflightFailurePayload }, + }; + } else { + try { + execution = await executeAgentTurn({ + commandBody: turn.queued.prompt, + transcriptCommandBody: turn.queued.transcriptPrompt, + followupRun: turn.queued, + sessionCtx, + replyOperation: turn.operation, + opts: progressOpts, + typingSignals, + blockReplyPipeline: null, + blockStreamingEnabled: false, + resolvedBlockStreamingBreak: turn.queued.run.blockReplyBreak, + applyReplyToMode: (payload) => payload, + shouldEmitToolResult, + shouldEmitToolOutput, + pendingToolTasks, + resetSessionAfterRoleOrderingConflict: async (reason) => { + const session = turn.session; + if (session.kind !== "session") { + return false; + } + return await resetReplyRunSession({ + options: { + failureLabel: "role ordering conflict", + buildLogMessage: (nextSessionId) => + `Role ordering conflict (${reason}). Restarting session ${session.key} -> ${nextSessionId}.`, + cleanupTranscripts: true, + }, + sessionKey: session.key, + queueKey: session.key, + activeSessionEntry: session.current(), + activeSessionStore: turn.sessionStore, + storePath: session.storePath, + messageThreadId: + sessionCtx.MessageThreadId != null ? String(sessionCtx.MessageThreadId) : undefined, + followupRun: turn.queued, + onActiveSessionEntry: (entry) => { + session.adopt(entry); + turn.operation.updateSessionId(entry.sessionId); + }, + onNewSession: () => undefined, + }); + }, + isHeartbeat: sourceOpts?.isHeartbeat === true, + sessionKey: turn.session.kind === "session" ? turn.session.key : undefined, + runtimePolicySessionKey: turn.queued.run.runtimePolicySessionKey, + getActiveSessionEntry: turn.session.current, + activeSessionStore: turn.sessionStore, + storePath: turn.session.kind === "session" ? turn.session.storePath : undefined, + resolvedVerboseLevel: currentVerboseLevel() ?? "off", + toolProgressDetail: defaults.toolProgressDetail, + onCompactionNoticePayload: (payload) => + enqueueProgress(() => + progressAllowed() + ? params.onCompactionNoticePayload(payload, { runId: turn.runId }) + : undefined, + ), + }); + } catch (error) { + while ( + pendingProgressTasks.size > 0 || + pendingToolTasks.size > 0 || + pendingToolTaskWatchers.size > 0 + ) { + await Promise.allSettled([ + ...pendingProgressTasks, + ...pendingToolTasks, + ...pendingToolTaskWatchers, + ]); + } + throw error; + } + } + return { + execution, + runStartedAt, + sessionCtx, + pendingToolTasks, + progress: { + drain: async () => { + let firstFailure: unknown = pendingProgressTaskFailure ?? pendingToolTaskFailure; + while ( + pendingProgressTasks.size > 0 || + pendingToolTasks.size > 0 || + pendingToolTaskWatchers.size > 0 + ) { + const results = await Promise.allSettled([ + ...pendingProgressTasks, + ...pendingToolTasks, + ...pendingToolTaskWatchers, + ]); + firstFailure ??= results.find((result) => result.status === "rejected")?.reason; + } + firstFailure ??= pendingProgressTaskFailure ?? pendingToolTaskFailure; + if (firstFailure !== undefined) { + throw firstFailure instanceof Error + ? firstFailure + : new Error(formatErrorMessage(firstFailure)); + } + }, + visibleToolErrorObserved: () => visibleToolError, + }, + }; +} diff --git a/src/auto-reply/reply/stranded-reply-recovery.test.ts b/src/auto-reply/reply/stranded-reply-recovery.test.ts index ded8e125fe9c..591565220c84 100644 --- a/src/auto-reply/reply/stranded-reply-recovery.test.ts +++ b/src/auto-reply/reply/stranded-reply-recovery.test.ts @@ -1,9 +1,6 @@ import { describe, expect, it, vi } from "vitest"; import { completeFollowupRunLifecycle, markFollowupRunEnqueued } from "./queue/types.js"; -import { - buildStrandedReplyRetryFollowupRun, - resolveStrandedReplyRecovery, -} from "./stranded-reply-recovery.js"; +import { resolveStrandedReplyRecovery } from "./stranded-reply-recovery.js"; import { createMockFollowupRun } from "./test-helpers.js"; const STRANDED_REPLY_RETRY_MARKER = "stranded-reply-retry"; @@ -24,10 +21,21 @@ describe("buildStrandedReplyRetryFollowupRun lifecycle ownership", () => { onReplyAdmissionWaitChange: vi.fn(), }); - const retry = buildStrandedReplyRetryFollowupRun(parent, { - finalText: "A substantive stranded final that must be re-delivered via message(action=send).", + const recovery = resolveStrandedReplyRecovery({ + base: parent, + finalText: + "A substantive stranded final must be re-delivered via message(action=send). It includes enough user-facing detail to require the one-shot recovery path.", sourceReplyDeliveryMode: "message_tool_only", + sendPolicyDenied: false, + successfulSourceReplyDelivery: false, + isHeartbeat: false, + isRoomEvent: false, }); + expect(recovery.kind).toBe("retry"); + if (recovery.kind !== "retry") { + throw new Error("expected retry recovery"); + } + const retry = recovery.run; expect(retry.turnAdoptionLifecycle).toBeUndefined(); expect(retry.strandedReplyRetry).toBe(true); diff --git a/src/auto-reply/reply/stranded-reply-recovery.ts b/src/auto-reply/reply/stranded-reply-recovery.ts index 69c6820a2018..5ec65bed0ab0 100644 --- a/src/auto-reply/reply/stranded-reply-recovery.ts +++ b/src/auto-reply/reply/stranded-reply-recovery.ts @@ -76,7 +76,7 @@ function buildStrandedReplyRetryPrompt(finalText: string): string { } /** Build the one-shot recovery followup that re-prompts message(action=send). */ -export function buildStrandedReplyRetryFollowupRun( +function buildStrandedReplyRetryFollowupRun( base: FollowupRun, params: { finalText: string; diff --git a/taxonomy.yaml b/taxonomy.yaml index bc0026612a8c..f390fb0820c1 100644 --- a/taxonomy.yaml +++ b/taxonomy.yaml @@ -1516,7 +1516,7 @@ surfaces: - docs/concepts/agent-runtimes.md search_anchors: - agent RPC shape and event stream - - runAgentTurnWithFallback + - executeAgentTurn - agent.wait timeout and terminal outcomes category_note: agent-turn-orchestration-and-runtime-lifecycle.md human_lts_override: true