Skip to content

Commit c604b58

Browse files
fix(webchat): finalize provider failure lifecycle
1 parent 69aca06 commit c604b58

5 files changed

Lines changed: 78 additions & 4 deletions

File tree

src/auto-reply/reply/agent-runner-execution.test.ts

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5198,6 +5198,8 @@ describe("runAgentTurnWithFallback", () => {
51985198
});
51995199

52005200
it("uses compact generic copy for raw external chat errors when verbose is off", async () => {
5201+
const agentEvents = await import("../../infra/agent-events.js");
5202+
const emitAgentEvent = vi.mocked(agentEvents.emitAgentEvent);
52015203
state.runEmbeddedAgentMock.mockRejectedValueOnce(
52025204
new Error("INVALID_ARGUMENT: some other failure"),
52035205
);
@@ -5210,7 +5212,7 @@ describe("runAgentTurnWithFallback", () => {
52105212
Provider: "whatsapp",
52115213
MessageSid: "msg",
52125214
} as unknown as TemplateContext,
5213-
opts: {},
5215+
opts: { runId: "run-provider-failure" } as GetReplyOptions,
52145216
typingSignals: createMockTypingSignaler(),
52155217
blockReplyPipeline: null,
52165218
blockStreamingEnabled: false,
@@ -5230,6 +5232,21 @@ describe("runAgentTurnWithFallback", () => {
52305232
if (result.kind === "final") {
52315233
expect(result.payload.text).toBe(GENERIC_RUN_FAILURE_TEXT);
52325234
}
5235+
const terminalFailureEvent = emitAgentEvent.mock.calls
5236+
.map((call) => call[0])
5237+
.find((event) => {
5238+
if (!event || typeof event !== "object") {
5239+
return false;
5240+
}
5241+
const data = (event as { data?: Record<string, unknown> }).data;
5242+
return (
5243+
(event as { runId?: unknown }).runId === "run-provider-failure" &&
5244+
(event as { stream?: unknown }).stream === "lifecycle" &&
5245+
data?.phase === "error" &&
5246+
data.finalFailure === true
5247+
);
5248+
});
5249+
expect(terminalFailureEvent).toBeDefined();
52335250
});
52345251

52355252
it("uses heartbeat failure copy for raw external errors during heartbeat runs", async () => {

src/auto-reply/reply/agent-runner-execution.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2932,6 +2932,17 @@ export async function runAgentTurnWithFallback(params: {
29322932
cfg: params.followupRun.run.config,
29332933
});
29342934

2935+
emitAgentEvent({
2936+
runId,
2937+
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
2938+
stream: "lifecycle",
2939+
data: {
2940+
phase: "error",
2941+
error: message,
2942+
endedAt: Date.now(),
2943+
finalFailure: true,
2944+
},
2945+
});
29352946
params.replyOperation?.fail("run_failed", err);
29362947
return {
29372948
kind: "final",

src/gateway/server-chat.agent-events.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2641,6 +2641,47 @@ describe("agent event handler", () => {
26412641
expect(agentRunSeq.has("run-terminal-error")).toBe(false);
26422642
});
26432643

2644+
it("finalizes fallback-exhausted lifecycle errors without waiting for retry grace", () => {
2645+
vi.useFakeTimers();
2646+
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({
2647+
resolveSessionKeyForRun: () => "session-terminal-error",
2648+
lifecycleErrorRetryGraceMs: 100,
2649+
});
2650+
registerAgentRunContext("run-terminal-final-failure", {
2651+
sessionKey: "session-terminal-error",
2652+
});
2653+
2654+
handler({
2655+
runId: "run-terminal-final-failure",
2656+
seq: 1,
2657+
stream: "lifecycle",
2658+
ts: Date.now(),
2659+
data: {
2660+
phase: "error",
2661+
error: "LLM request failed: network connection error.",
2662+
finalFailure: true,
2663+
},
2664+
});
2665+
2666+
const finalPayload = chatBroadcastCalls(broadcast).at(-1)?.[1] as {
2667+
state?: string;
2668+
runId?: string;
2669+
errorMessage?: string;
2670+
};
2671+
expect(finalPayload.state).toBe("error");
2672+
expect(finalPayload.runId).toBe("run-terminal-final-failure");
2673+
expect(finalPayload.errorMessage).toContain("network connection error");
2674+
expect(clearAgentRunContext).toHaveBeenCalledWith("run-terminal-final-failure");
2675+
expect(agentRunSeq.has("run-terminal-final-failure")).toBe(false);
2676+
expect(
2677+
persistGatewaySessionLifecycleEventMock.mock.calls.some(
2678+
([params]) =>
2679+
(params as { event?: { data?: { finalFailure?: boolean } } }).event?.data
2680+
?.finalFailure === true,
2681+
),
2682+
).toBe(true);
2683+
});
2684+
26442685
it("keeps deferred lifecycle-error cleanup across later non-terminal events", () => {
26452686
vi.useFakeTimers();
26462687
const { broadcast, clearAgentRunContext, agentRunSeq, handler } = createHarness({

src/gateway/server-chat.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1232,7 +1232,11 @@ export function createAgentEventHandler({
12321232
if (lifecyclePhase === "error") {
12331233
clearBufferedChatState(clientRunId);
12341234
const skipChatErrorFinal = isChatSendRunActive(evt.runId) && !chatLink;
1235-
if (isAborted || lifecycleErrorRetryGraceMs <= 0) {
1235+
const isFinalFailure = evt.data?.finalFailure === true;
1236+
// Per-attempt provider errors keep the retry grace so fallback can reuse
1237+
// the runId. Once the runner marks fallback as exhausted, clear chat state
1238+
// immediately so webchat sessions do not stay in progress until the timer.
1239+
if (isAborted || isFinalFailure || lifecycleErrorRetryGraceMs <= 0) {
12361240
finalizeLifecycleEvent(evt, { skipChatErrorFinal });
12371241
} else {
12381242
scheduleTerminalLifecycleError(evt, { skipChatErrorFinal });

test/scripts/check-deadcode-unused-files.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,9 @@ src/a.ts: src/a.ts
208208

209209
await resultPromise;
210210

211-
expect(calls[0]).toMatchObject({
211+
const call = calls[0] as { command: string };
212+
expect(path.basename(call.command)).toBe("pnpm");
213+
expect(call).toMatchObject({
212214
args: [
213215
"--config.minimum-release-age=0",
214216
"dlx",
@@ -224,7 +226,6 @@ src/a.ts: src/a.ts
224226
"--files",
225227
"--no-config-hints",
226228
],
227-
command: "pnpm",
228229
options: {
229230
detached: process.platform !== "win32",
230231
shell: false,

0 commit comments

Comments
 (0)