mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
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:
committed by
GitHub
parent
ab0b749017
commit
f8cc39ce62
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
+146
-2019
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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user