Skip to content

Commit 3cb013e

Browse files
committed
fix(cron): respect recovered tool errors
1 parent b7ba7c3 commit 3cb013e

2 files changed

Lines changed: 81 additions & 13 deletions

File tree

src/cron/isolated-agent.helpers.test.ts

Lines changed: 35 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,11 @@ describe("resolveCronPayloadOutcome", () => {
6262
});
6363

6464
expect(result.hasFatalErrorPayload).toBe(false);
65+
expect(result.errorPayloadRecovery).toEqual({
66+
hadErrorPayload: true,
67+
recovered: true,
68+
recoveredBy: "laterPayload",
69+
});
6570
expect(result.summary).toBe("Write completed successfully.");
6671
});
6772

@@ -73,6 +78,11 @@ describe("resolveCronPayloadOutcome", () => {
7378
});
7479

7580
expect(result.hasFatalErrorPayload).toBe(false);
81+
expect(result.errorPayloadRecovery).toEqual({
82+
hadErrorPayload: true,
83+
recovered: true,
84+
recoveredBy: "presentationWarning",
85+
});
7686
expect(result.embeddedRunError).toBeUndefined();
7787
expect(result.pendingPresentationWarningError).toBe("⚠️ ✉️ Message failed");
7888
expect(result.summary).toBe("Final cron report");
@@ -281,7 +291,7 @@ describe("resolveCronPayloadOutcome", () => {
281291
expect(result.deliveryPayloadHasStructuredContent).toBe(true);
282292
});
283293

284-
it("returns only the last error payload when all payloads are errors", () => {
294+
it("uses terminal assistant text when all error payloads were recovered", () => {
285295
const result = resolveCronPayloadOutcome({
286296
payloads: [
287297
{ text: "first error", isError: true },
@@ -291,11 +301,33 @@ describe("resolveCronPayloadOutcome", () => {
291301
preferFinalAssistantVisibleText: true,
292302
});
293303

294-
expect(result.outputText).toBe("last error");
295-
expect(result.deliveryPayloads).toEqual([{ text: "last error", isError: true }]);
304+
expect(result.hasFatalErrorPayload).toBe(false);
305+
expect(result.errorPayloadRecovery).toEqual({
306+
hadErrorPayload: true,
307+
recovered: true,
308+
recoveredBy: "terminalAssistantText",
309+
});
310+
expect(result.outputText).toBe("Recovered final answer");
311+
expect(result.deliveryPayloads).toEqual([{ text: "Recovered final answer" }]);
296312
expect(result.deliveryPayload).toEqual({ text: "last error", isError: true });
297313
});
298314

315+
it("keeps recovered error payloads fatal when final assistant text reports denial", () => {
316+
const result = resolveCronPayloadOutcome({
317+
payloads: [{ text: "tool error", isError: true }],
318+
finalAssistantVisibleText: "I could not run the requested script.",
319+
preferFinalAssistantVisibleText: true,
320+
});
321+
322+
expect(result.hasFatalErrorPayload).toBe(true);
323+
expect(result.errorPayloadRecovery).toEqual({
324+
hadErrorPayload: true,
325+
recovered: false,
326+
});
327+
expect(result.embeddedRunError).toBe("tool error");
328+
expect(result.deliveryPayloads).toEqual([{ text: "tool error", isError: true }]);
329+
});
330+
299331
it("keeps multi-payload direct delivery when finalAssistantVisibleText is not preferred", () => {
300332
const result = resolveCronPayloadOutcome({
301333
payloads: [{ text: "Working on it..." }, { text: "Final weather summary" }],

src/cron/isolated-agent/helpers.ts

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,11 @@ export type CronPayloadOutcome = {
1818
deliveryPayloads: DeliveryPayload[];
1919
deliveryPayloadHasStructuredContent: boolean;
2020
hasFatalErrorPayload: boolean;
21+
errorPayloadRecovery: {
22+
hadErrorPayload: boolean;
23+
recovered: boolean;
24+
recoveredBy?: "laterPayload" | "terminalAssistantText" | "presentationWarning";
25+
};
2126
embeddedRunError?: string;
2227
pendingPresentationWarningError?: string;
2328
};
@@ -274,29 +279,61 @@ export function resolveCronPayloadOutcome(params: {
274279
const normalizedFinalAssistantVisibleText = normalizeOptionalString(
275280
params.finalAssistantVisibleText,
276281
);
282+
const failureSignal = normalizeCronFailureSignal(params.failureSignal);
283+
const runLevelError = formatCronRunLevelError(params.runLevelError);
284+
const hasStructuredDeliveryPayloads = selectedDeliveryPayloads.some((payload) =>
285+
payloadHasStructuredDeliveryContent(payload),
286+
);
277287
const hasSuccessfulPayloadAfterLastError =
278-
!params.runLevelError &&
288+
!runLevelError &&
279289
lastErrorPayloadIndex >= 0 &&
280290
params.payloads
281291
.slice(lastErrorPayloadIndex + 1)
282292
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
283293
const hasSuccessfulPayloadBeforeLastError =
284-
!params.runLevelError &&
294+
!runLevelError &&
285295
lastErrorPayloadIndex > 0 &&
286296
params.payloads
287297
.slice(0, lastErrorPayloadIndex)
288298
.some((payload) => payload?.isError !== true && Boolean(payload?.text?.trim()));
289299
const hasPendingPresentationWarning =
290-
!params.runLevelError &&
291-
params.failureSignal?.fatalForCron !== true &&
300+
!runLevelError &&
301+
failureSignal === undefined &&
292302
lastErrorPayloadIndex >= 0 &&
293303
isCronMessagePresentationWarning(lastErrorPayloadText) &&
294304
(normalizedFinalAssistantVisibleText !== undefined || hasSuccessfulPayloadBeforeLastError);
305+
const hasTerminalAssistantRecovery =
306+
!runLevelError &&
307+
failureSignal === undefined &&
308+
params.preferFinalAssistantVisibleText === true &&
309+
normalizedFinalAssistantVisibleText !== undefined &&
310+
detectCronDenialToken(normalizedFinalAssistantVisibleText) === undefined &&
311+
!hasStructuredDeliveryPayloads &&
312+
lastErrorPayloadIndex >= 0 &&
313+
!hasSuccessfulPayloadAfterLastError &&
314+
(!hasSuccessfulPayloadBeforeLastError ||
315+
normalizedFinalAssistantVisibleText !== fallbackOutputText);
295316
const hasFatalStructuredErrorPayload =
296-
hasErrorPayload && !hasSuccessfulPayloadAfterLastError && !hasPendingPresentationWarning;
297-
const hasStructuredDeliveryPayloads = selectedDeliveryPayloads.some((payload) =>
298-
payloadHasStructuredDeliveryContent(payload),
299-
);
317+
hasErrorPayload &&
318+
!hasSuccessfulPayloadAfterLastError &&
319+
!hasPendingPresentationWarning &&
320+
!hasTerminalAssistantRecovery;
321+
const errorPayloadRecoveredBy = hasSuccessfulPayloadAfterLastError
322+
? ("laterPayload" as const)
323+
: hasPendingPresentationWarning
324+
? ("presentationWarning" as const)
325+
: hasTerminalAssistantRecovery
326+
? ("terminalAssistantText" as const)
327+
: undefined;
328+
const errorPayloadRecovery = {
329+
hadErrorPayload: hasErrorPayload,
330+
recovered:
331+
hasErrorPayload &&
332+
(hasSuccessfulPayloadAfterLastError ||
333+
hasPendingPresentationWarning ||
334+
hasTerminalAssistantRecovery),
335+
...(errorPayloadRecoveredBy ? { recoveredBy: errorPayloadRecoveredBy } : {}),
336+
};
300337
// Keep structured/media announce payloads intact. Only collapse purely textual
301338
// cron announce output to the final assistant-visible answer.
302339
const shouldUseFinalAssistantVisibleText =
@@ -329,8 +366,6 @@ export function resolveCronPayloadOutcome(params: {
329366
text: payload?.text,
330367
})),
331368
]);
332-
const failureSignal = normalizeCronFailureSignal(params.failureSignal);
333-
const runLevelError = formatCronRunLevelError(params.runLevelError);
334369
const hasFatalErrorPayload =
335370
hasFatalStructuredErrorPayload ||
336371
failureSignal !== undefined ||
@@ -361,6 +396,7 @@ export function resolveCronPayloadOutcome(params: {
361396
? false
362397
: deliveryPayloadHasStructuredContent,
363398
hasFatalErrorPayload,
399+
errorPayloadRecovery,
364400
embeddedRunError: structuredErrorText
365401
? structuredErrorText
366402
: failureSignal

0 commit comments

Comments
 (0)