Skip to content

Commit 3b4747e

Browse files
committed
fix: surface preflight compaction failures
1 parent 5cf430b commit 3b4747e

4 files changed

Lines changed: 166 additions & 13 deletions

File tree

src/auto-reply/reply/agent-runner-direct-runtime-config.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,29 @@ describe("runReplyAgent runtime config", () => {
306306
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
307307
});
308308

309+
it("surfaces preflight compaction failures before the agent starts", async () => {
310+
const { replyParams } = createDirectRuntimeReplyParams({
311+
shouldFollowup: false,
312+
isActive: false,
313+
});
314+
runPreflightCompactionIfNeededMock.mockRejectedValue(
315+
new Error("Preflight compaction required but failed: auth profile mismatch"),
316+
);
317+
runMemoryFlushIfNeededMock.mockResolvedValue(undefined);
318+
319+
const result = await runReplyAgent(replyParams);
320+
321+
if (!result || Array.isArray(result)) {
322+
throw new Error("expected a single preflight compaction failure reply payload");
323+
}
324+
expect(result.text).toContain("Context is too large");
325+
expect(result.text).toContain("auto-compaction could not recover");
326+
expect(result.text).toContain("/compact");
327+
expect(result.text).toContain("/new");
328+
const metadata = getReplyPayloadMetadata(result);
329+
expect(metadata?.deliverDespiteSourceReplySuppression).toBe(true);
330+
});
331+
309332
it("does not resolve secrets before the enqueue-followup queue path", async () => {
310333
const { followupRun, resolvedQueue, replyParams } = createDirectRuntimeReplyParams({
311334
shouldFollowup: true,

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

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -621,6 +621,7 @@ function collapseRepeatedFailureDetail(message: string): string {
621621
const SAFE_MISSING_API_KEY_PROVIDERS = new Set(["anthropic", "google", "openai", "openai-codex"]);
622622
const EXTERNAL_RUN_FAILURE_DETAIL_MAX_CHARS = 900;
623623
const AGENT_FAILED_BEFORE_REPLY_TEXT = "Agent failed before reply:";
624+
const PREFLIGHT_COMPACTION_FAILURE_PREFIX = "Preflight compaction required but failed:";
624625

625626
type ExternalRunFailureReply = {
626627
text: string;
@@ -688,6 +689,27 @@ function buildCodexAppServerFailureText(message: string): string | null {
688689
return null;
689690
}
690691

692+
export function buildPreflightCompactionFailureText(
693+
message: string,
694+
options?: { includeDetails?: boolean },
695+
): string | null {
696+
const normalizedMessage = collapseRepeatedFailureDetail(message);
697+
if (!normalizedMessage.startsWith(PREFLIGHT_COMPACTION_FAILURE_PREFIX)) {
698+
return null;
699+
}
700+
const reason = sanitizeUserFacingText(
701+
normalizedMessage.slice(PREFLIGHT_COMPACTION_FAILURE_PREFIX.length),
702+
{ errorContext: true },
703+
)
704+
.trim()
705+
.replace(/\s+/gu, " ");
706+
const reasonSuffix = options?.includeDetails && reason ? ` Reason: ${reason}.` : "";
707+
return (
708+
"⚠️ Context is too large and auto-compaction could not recover this turn." +
709+
`${reasonSuffix} Try again, use /compact, or use /new to start a fresh session.`
710+
);
711+
}
712+
691713
function buildCliBackendTimeoutFailureText(message: string): string | null {
692714
const normalizedMessage = collapseRepeatedFailureDetail(message);
693715
const stall = normalizedMessage.match(CLI_BACKEND_NO_OUTPUT_STALL_RE);
@@ -816,6 +838,20 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
816838
});
817839
}
818840

841+
const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
842+
includeDetails: isVerboseFailureDetailEnabled(params.resolvedVerboseLevel),
843+
});
844+
if (preflightCompactionFailureText) {
845+
return markAgentRunFailureReplyPayload({
846+
text: resolveExternalRunFailureTextForConversation({
847+
text: preflightCompactionFailureText,
848+
sessionCtx: params.sessionCtx,
849+
isGenericRunnerFailure: false,
850+
cfg: params.cfg,
851+
}),
852+
});
853+
}
854+
819855
const isPureTransientSummary = isFallbackSummary
820856
? isPureTransientRateLimitSummary(params.err)
821857
: false;

src/auto-reply/reply/followup-runner.test.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -646,6 +646,77 @@ describe("createFollowupRunner reply-lane admission", () => {
646646
expect(call.sessionId).toBe("post-compact-session");
647647
expect(call.sessionFile).toBe("/tmp/post-compact.jsonl");
648648
});
649+
650+
it("routes preflight compaction failures before starting queued followup runs", async () => {
651+
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(
652+
new Error("Preflight compaction required but failed: auth profile mismatch"),
653+
);
654+
const runner = createFollowupRunner({
655+
typing: createMockTypingController(),
656+
typingMode: "instant",
657+
sessionKey: "main",
658+
defaultModel: "anthropic/claude",
659+
});
660+
661+
await runner(
662+
createQueuedRun({
663+
originatingChannel: "discord",
664+
originatingTo: "channel:C1",
665+
originatingAccountId: "acct-1",
666+
originatingThreadId: "thread-1",
667+
originatingChatType: "group",
668+
run: {
669+
messageProvider: "discord",
670+
provider: "anthropic",
671+
model: "claude",
672+
verboseLevel: "off",
673+
sessionKey: "main",
674+
},
675+
}),
676+
);
677+
678+
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
679+
expect(routeReplyMock).toHaveBeenCalledOnce();
680+
expect(routeReplyMock).toHaveBeenCalledWith(
681+
expect.objectContaining({
682+
channel: "discord",
683+
to: "channel:C1",
684+
accountId: "acct-1",
685+
threadId: "thread-1",
686+
payload: expect.objectContaining({
687+
text: expect.stringContaining("auto-compaction could not recover"),
688+
}),
689+
}),
690+
);
691+
});
692+
693+
it("preserves non-compaction preflight failures for queued followup runs", async () => {
694+
runPreflightCompactionIfNeededMock.mockRejectedValueOnce(new Error("session load failed"));
695+
const runner = createFollowupRunner({
696+
typing: createMockTypingController(),
697+
typingMode: "instant",
698+
sessionKey: "main",
699+
defaultModel: "anthropic/claude",
700+
});
701+
702+
await expect(
703+
runner(
704+
createQueuedRun({
705+
originatingChannel: "discord",
706+
originatingTo: "channel:C1",
707+
run: {
708+
messageProvider: "discord",
709+
provider: "anthropic",
710+
model: "claude",
711+
sessionKey: "main",
712+
},
713+
}),
714+
),
715+
).rejects.toThrow("session load failed");
716+
717+
expect(runEmbeddedAgentMock).not.toHaveBeenCalled();
718+
expect(routeReplyMock).not.toHaveBeenCalled();
719+
});
649720
});
650721

651722
async function normalizeComparablePath(filePath: string): Promise<string> {

src/auto-reply/reply/followup-runner.ts

Lines changed: 36 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -28,9 +28,11 @@ import { defaultRuntime } from "../../runtime.js";
2828
import { shouldPreserveUserFacingSessionStateForInputProvenance } from "../../sessions/input-provenance.js";
2929
import { readStringValue } from "../../shared/string-coerce.js";
3030
import { isInternalMessageChannel } from "../../utils/message-channel.js";
31+
import { markReplyPayloadForSourceSuppressionDelivery } from "../reply-payload.js";
3132
import type { GetReplyOptions, ReplyPayload } from "../types.js";
3233
import { runCliAgentWithLifecycle } from "./agent-runner-cli-dispatch.js";
3334
import {
35+
buildPreflightCompactionFailureText,
3436
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
3537
resolveSessionRuntimeOverrideForProvider,
3638
} from "./agent-runner-execution.js";
@@ -535,19 +537,40 @@ export function createFollowupRunner(params: {
535537
let runResult: Awaited<ReturnType<typeof runEmbeddedAgent>>;
536538
let fallbackProvider = run.provider;
537539
let fallbackModel = run.model;
538-
activeSessionEntry = await runPreflightCompactionIfNeeded({
539-
cfg: runtimeConfig,
540-
followupRun: effectiveQueued,
541-
promptForEstimate: queued.prompt,
542-
defaultModel,
543-
agentCfgContextTokens,
544-
sessionEntry: activeSessionEntry,
545-
sessionStore,
546-
sessionKey: replySessionKey,
547-
storePath,
548-
isHeartbeat: opts?.isHeartbeat === true,
549-
replyOperation,
550-
});
540+
try {
541+
activeSessionEntry = await runPreflightCompactionIfNeeded({
542+
cfg: runtimeConfig,
543+
followupRun: effectiveQueued,
544+
promptForEstimate: queued.prompt,
545+
defaultModel,
546+
agentCfgContextTokens,
547+
sessionEntry: activeSessionEntry,
548+
sessionStore,
549+
sessionKey: replySessionKey,
550+
storePath,
551+
isHeartbeat: opts?.isHeartbeat === true,
552+
replyOperation,
553+
});
554+
} catch (err) {
555+
const message = formatErrorMessage(err);
556+
replyOperation.fail("run_failed", err);
557+
const preflightCompactionFailureText = buildPreflightCompactionFailureText(message, {
558+
includeDetails: run.verboseLevel === "on" || run.verboseLevel === "full",
559+
});
560+
if (preflightCompactionFailureText) {
561+
await sendFollowupPayloads(
562+
[
563+
markReplyPayloadForSourceSuppressionDelivery({
564+
text: preflightCompactionFailureText,
565+
}),
566+
],
567+
effectiveQueued,
568+
{ provider: fallbackProvider, modelId: fallbackModel },
569+
);
570+
return;
571+
}
572+
throw err;
573+
}
551574
let bootstrapPromptWarningSignaturesSeen = resolveBootstrapWarningSignaturesSeen(
552575
activeSessionEntry?.systemPromptReport,
553576
);

0 commit comments

Comments
 (0)