refactor(reply): converge queued follow-up execution [AI-assisted] (#114599)

* refactor(reply): converge queued follow-up execution

* fix(reply): preserve follow-up lifecycle invariants

* fix(reply): fence follow-up admission generation

* fix(reply): reconcile follow-up preflight state

* fix(reply): fence persisted follow-up generation

* fix(reply): defer follow-up compaction terminal notices

* style(reply): format follow-up admission

* fix(reply): normalize follow-up session metadata

* fix(reply): fence in-memory follow-up generation

* test(reply): satisfy follow-up lint contracts

* fix(reply): revalidate failed follow-up preflight

* fix(reply): reject deleted follow-up generation

* fix(reply): release restart-aborted operations

* test(reply): avoid unbound operation assertion

* fix(reply): refresh follow-up presentation state

* test(reply): use canonical plan update shape

* fix(reply): harden follow-up progress delivery

* fix(reply): synchronize recovered follow-up operation

* test(reply): mark partial operation fixture

* fix(reply): adopt owned preflight rotations

* docs(reply): clarify restart settlement guard

* test(reply): preserve terminal notice call count

* refactor(reply): remove retired follow-up exports

* test(reply): prove dispatcher-only follow-up routing

* chore(reply): remove stale delivery type import

* fix(reply): finalize follow-up cleanup and failure delivery

* fix(reply): prefer authoritative admission snapshot

* fix(reply): avoid replay on progress failure

* fix(reply): retain suppressed-mode terminal failures

* fix(reply): preserve follow-up session context

* fix(reply): preserve exclusive delivery and deleted state

* fix(reply): prevent post-start follow-up replay

* fix(reply): prioritize safe follow-up failure delivery

* fix(reply): signal follow-up execution after start

* fix(reply): harden follow-up settlement

* refactor(reply): keep session clear internal

* fix(reply): consume pre-run user aborts

* fix(reply): retain progress delivery failure

* fix(ci): register codex prewarm test shard

* test(reply): retain operation mock type
This commit is contained in:
Peter Steinberger
2026-07-27 15:06:11 -04:00
committed by GitHub
parent ab0b749017
commit f8cc39ce62
44 changed files with 5154 additions and 9517 deletions
-2
View File
@@ -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
@@ -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 () => {
@@ -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<AgentRunLoopResult, { kind: "final" }>;
| Extract<AgentTurnInternalResult, { kind: "final" }>;
export async function handleAgentExecutionError(params: {
turn: AgentTurnParams;
+11 -6
View File
@@ -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,
@@ -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: {
@@ -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<NonNullable<GetReplyOptions["onPartialReply"]>>(
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<NonNullable<GetReplyOptions["onToolStart"]>>(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<NonNullable<GetReplyOptions["onItemEvent"]>>(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<NonNullable<GetReplyOptions["onItemEvent"]>>();
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<NonNullable<GetReplyOptions["onToolStart"]>>(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<NonNullable<GetReplyOptions["onPartialReply"]>>(
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<NonNullable<GetReplyOptions["onReasoningStream"]>>(
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<NonNullable<GetReplyOptions["onReasoningStream"]>>(
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<NonNullable<GetReplyOptions["onReasoningStream"]>>(
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<NonNullable<GetReplyOptions["onReasoningStream"]>>(
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<NonNullable<GetReplyOptions["onReasoningStream"]>>(
async (_payload) => undefined,
);
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
const executeAgentTurn = await getExecuteAgentTurnForTest();
await runAgentTurnWithFallback(
await executeAgentTurn(
createMinimalRunAgentTurnParams({
opts: { onReasoningStream },
}),
@@ -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,
@@ -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<Promise<void>>();
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: {
@@ -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: {
@@ -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") {
@@ -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();
});
});
@@ -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: {
@@ -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"],
@@ -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<void>((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: {
@@ -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,
@@ -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<Promise<void>>();
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: {
@@ -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<unknown>((resolve) => {
@@ -601,7 +601,7 @@ describe("runAgentTurnWithFallback: provider failures", () => {
.mockImplementationOnce(() => retryResult);
const onBlockReply = vi.fn((..._args: unknown[]) => new Promise<void>(() => {}));
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<unknown>((_resolve, reject) => {
@@ -640,7 +640,7 @@ describe("runAgentTurnWithFallback: provider failures", () => {
});
const onBlockReply = vi.fn((..._args: unknown[]) => new Promise<void>(() => {}));
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<unknown>((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: {
@@ -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<Promise<void>>();
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<Promise<void>>();
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<Promise<void>>();
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<Promise<void>>();
const result = await runAgentTurnWithFallback({
const result = await executeAgentTurn({
commandBody: "hello",
followupRun: createFollowupRun(),
sessionCtx: {
@@ -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: () =>
({
@@ -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", {
@@ -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: {
@@ -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<typeof execute>) => {
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 = {
+120 -35
View File
@@ -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<AgentRunLoopResult> {
): Promise<AgentTurnInternalResult> {
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<AgentRunLoopResult> {
): Promise<AgentTurnInternalResult> {
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<AgentRunLoopResult> {
/** Runs the agent turn with provider/model fallback, retry, and closed settlement. */
export async function executeAgentTurn(params: AgentTurnParams): Promise<AgentTurnExecutionResult> {
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;
}
}
@@ -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<ReturnType<typeof runEmbeddedAgent>>;
kind: "completed";
result: Awaited<ReturnType<typeof runEmbeddedAgent>>;
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<ReturnType<typeof runEmbeddedAgent>>;
resolved: { provider: string; model: string };
fallback: { exhausted: boolean; attempts: RuntimeFallbackAttempt[] };
autoCompactionCount: number;
didLogHeartbeatStrip: boolean;
directlySentBlockKeys?: Set<string>;
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 = {
@@ -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<AgentRunLoopResult, { kind: "final" }>;
| Extract<AgentTurnInternalResult, { kind: "final" }>;
type AgentFallbackModelPatch = {
captureFallbackFailure: (attempts: RuntimeFallbackAttempt[]) => boolean | undefined;
@@ -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<ReturnType<typeof accountAgentTurn>>;
/** 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 };
}
@@ -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
@@ -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<AgentRunLoopResult, { kind: "success" }>;
export type FinalizeReplyAgentRunInput = Pick<
RunReplyAgentParams,
| "agentCfgContextTokens"
@@ -47,7 +45,8 @@ export type FinalizeReplyAgentRunInput = Pick<
replyToMode: ReturnType<typeof resolveReplyToMode>;
returnWithQueuedFollowupDrain: <T>(value: T) => T;
runFollowupTurn: (queued: FollowupRun) => Promise<void>;
runOutcome: SuccessfulAgentRun;
execution: SettledAgentTurn;
runId: string;
runStartedAt: number;
typingSignals: TypingSignaler;
};
@@ -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";
@@ -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<typeof import("./agent-runner-execution.js").runAgentTurnWithFallback>
type AgentTurnExecutionResult = Awaited<
ReturnType<typeof import("./agent-runner-execution.js").executeAgentTurn>
>;
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(
@@ -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<typeof import("./reply-media-paths.runtime.js")>();
return {
@@ -642,8 +642,8 @@ describe("runReplyAgent media path normalization", () => {
sessionCtx: TemplateContext,
prompt = "describe this image",
): Promise<void> {
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: {
@@ -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.
@@ -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;
}
+527 -1
View File
@@ -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> = {}): 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<string, unknown> = {},
) {
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<void>) => ({
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"),
);
});
});
+481 -127
View File
@@ -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<void> {
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<void>;
kind?: ReplyDispatchKind;
}): Promise<void> {
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,
});
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -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<string, SessionEntry>;
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<string, SessionEntry>;
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<string, SessionEntry>;
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<string, SessionEntry>;
}): Record<string, SessionEntry> | 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<void>;
}): Promise<FollowupAdmissionResult> {
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<CompactionNoticePhase, "start"> | 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));
}
}
@@ -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> = {}): 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<void>((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<unknown>;
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<void>((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<void>((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"]);
});
});
@@ -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<Promise<void>>;
progress: {
drain(): Promise<void>;
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<void>;
onCompactionNoticePayload: (payload: ReplyPayload, execution: { runId: string }) => Promise<void>;
}): Promise<FollowupExecutionResult> {
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<void> = Promise.resolve();
let pendingProgressTaskFailure: unknown;
const pendingProgressTasks = new Set<Promise<void>>();
const enqueueProgress = (deliver: () => Promise<void> | void): Promise<void> => {
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 = <T>(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<Promise<void>>();
const pendingToolTasks = new (class extends Set<Promise<void>> {
override add(task: Promise<void>): 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,
},
};
}
@@ -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);
@@ -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;
+1 -1
View File
@@ -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