Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 0 additions & 5 deletions src/context-compactor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -848,11 +848,9 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
baseURL: "http://localhost:1",
credentialId: "test",
};
const notices: string[] = [];
const summarize = createModelSummarizer({
getSource: () => source,
complete: async () => "",
onFailure: (text) => notices.push(text),
});
const result = await smallCompactor({
summaryMaxChars: 500,
Expand All @@ -862,9 +860,6 @@ describe("createPruningCompactor — consolidated handoff (CL-7521)", () => {
expect(result.record.decisions.summarizeFailureKind).toBe("empty");
expect(result.record.reason).toContain("statistics-only stub: empty");
expect(compactedTurns(result.output)).toHaveLength(1);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("empty");
});

test("an aborted summarizer keeps prior context instead of stubbing", async () => {
Expand Down
4 changes: 4 additions & 0 deletions src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -943,6 +943,10 @@ export async function runExec(config: Config): Promise<ExecResult> {
currentStorage?.readBlob.bind(currentStorage),
),
telemetry: liveTelemetry,
// Operator-visible once a statistics-only stub actually replaces turns.
onFailure: (text) => {
stderr.write(`${text}\n`);
},
onFolded: () => {
// Fold restarts the cached prefix — drop idle execute-promoted
// schemas rather than carrying them forever, and persist so a
Expand Down
11 changes: 1 addition & 10 deletions src/session/compaction-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -246,9 +246,6 @@ describe("createCompactionLifecycle", () => {
}) as never,
getSignal: () => lifecycle.getSignal(),
telemetry,
onFailure: (text) => {
notices.push(text);
},
complete: (_promptTurns, _source, signal) =>
new Promise<string>((_resolve, reject) => {
signal.addEventListener(
Expand Down Expand Up @@ -297,8 +294,7 @@ describe("createCompactionLifecycle", () => {
expect(telemetryEvents).toEqual([]);
});

test("a genuine summarizer failure still notifies and emits telemetry", async () => {
const notices: string[] = [];
test("a genuine summarizer failure still emits telemetry", async () => {
const captured: {
event: string;
properties?: Record<string, unknown> | undefined;
Expand All @@ -322,16 +318,11 @@ describe("createCompactionLifecycle", () => {
credentialId: "test",
}) as never,
telemetry,
onFailure: (text) => {
notices.push(text);
},
complete: async () => {
throw new Error("model unreachable");
},
});
await expect(summarize(turns(5))).rejects.toThrow("model unreachable");
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("Compaction summary failed");
const failures = captured.filter((e) => e.event === "summarizer_failure");
expect(failures).toHaveLength(1);
expect(failures[0]?.properties?.["error_kind"]).toBe("failed");
Expand Down
121 changes: 117 additions & 4 deletions src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,9 +567,10 @@ describe("createSessionPruningCompactor", () => {
});

describe("createSessionPruningCompactor stub fallback", () => {
test("a summarizer stub fallback folds without success telemetry or onFolded", async () => {
test("a summarizer stub fallback folds without success telemetry and still runs onFolded", async () => {
const captured: { event: string }[] = [];
const folds: { turnsBefore: number; turnsAfter: number }[] = [];
const folds: { turnsBefore: number; turnsAfter: number; stub: boolean }[] =
[];
const telemetry: Telemetry = {
enabled: true,
installationId: "test",
Expand All @@ -593,12 +594,12 @@ describe("createSessionPruningCompactor stub fallback", () => {
complete: async () => {
throw new Error("model unreachable");
},
onFailure: (text) => notices.push(text),
});
const compactor = createSessionPruningCompactor({
summarize,
telemetry,
onFolded: (info) => folds.push(info),
onFailure: (text) => notices.push(text),
compactionShape: { tailBudgetTokens: 1 },
});
const now = Date.now();
Expand All @@ -613,11 +614,123 @@ describe("createSessionPruningCompactor stub fallback", () => {
});
expect(result.record.decisions.summarizeFailed).toBe(1);
expect(result.record.reason).toContain("statistics-only stub");
expect(folds).toEqual([]);
expect(folds).toEqual([
{ turnsBefore: 8, turnsAfter: result.output.length, stub: true },
]);
expect(captured).toEqual([]);
expect(notices).toHaveLength(1);
expect(notices[0]).toContain("statistics-only stub");
expect(notices[0]).toContain("failed");
expect(notices[0]).toContain("model unreachable");
});

test("verify abort after a failed summary keeps prior context and fires no stub notice", async () => {
const notices: string[] = [];
const folds: { stub: boolean }[] = [];
const summarize = createModelSummarizer({
getSource: () =>
({
id: "test",
provider: "openai",
model: "test-model",
baseURL: "http://localhost:1",
credentialId: "test",
}) as never,
complete: async () => {
throw new Error("model unreachable");
},
});
const now = Date.now();
const turns = [
{
role: "user" as const,
content: [
{
type: "text" as const,
text: "Migrate the auth module to opaque tokens",
},
],
timestamp: now,
},
{
role: "assistant" as const,
content: [
{
type: "tool_call" as const,
id: "c1",
name: "run_shell",
arguments: { command: "bun test auth" },
},
],
timestamp: now,
},
{
role: "user" as const,
content: [
{
type: "tool_result" as const,
callId: "c1",
isError: true,
content: [
{ type: "text" as const, text: "token refresh assertion failed" },
],
},
],
timestamp: now,
},
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "Working through the failure" },
],
timestamp: now,
},
{
role: "user" as const,
content: [
{
type: "text" as const,
text: "Confirm there are no errors remaining in auth",
},
],
timestamp: now,
},
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "Continuing the auth work now" },
],
timestamp: now,
},
{
role: "user" as const,
content: [
{
type: "text" as const,
text: "There are no errors remaining in the suite",
},
],
timestamp: now,
},
{
role: "assistant" as const,
content: [
{ type: "text" as const, text: "I will keep going from here" },
],
timestamp: now,
},
];
const result = await createSessionPruningCompactor({
summarize,
onFolded: (info) => folds.push(info),
onFailure: (text) => notices.push(text),
compactionShape: { tailBudgetTokens: 1 },
}).apply(turns as never, { state: {} as never, trigger: "test" });
expect(result.output).toBe(turns);
expect(result.record.reason).toBe("verify failed — keeping prior context");
expect(result.record.decisions.summarizedTurnCount).toBeUndefined();
expect(folds).toEqual([]);
expect(notices).toEqual([]);
});
});

Expand Down
90 changes: 73 additions & 17 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,11 @@ import {
createPruningCompactor,
type CompactionShape,
} from "./compactor.js";
import type { SummaryContext } from "./summarizer.js";
import {
classifySummarizerFailure,
summarizerStubFallbackNotice,
type SummaryContext,
} from "./summarizer.js";
import { NOOP_TELEMETRY, type Telemetry } from "../telemetry/index.js";
import { COMPACTION_ABORTED_REASON } from "./compaction-lifecycle.js";

Expand Down Expand Up @@ -418,7 +422,17 @@ export interface SessionPruningCompactorArgs {
readPriorHandoff?: () => Promise<string | undefined>;
telemetry?: Telemetry;
/** Fires only when turns were actually folded away — not on no-ops. */
onFolded?: (info: { turnsBefore: number; turnsAfter: number }) => void;
onFolded?: (info: {
turnsBefore: number;
turnsAfter: number;
/** True when the fold used a statistics-only stub, not an LLM summary. */
stub: boolean;
}) => void;
/**
* Operator-visible notice for a statistics-only stub that actually replaced
* turns. Verify abort (keeping prior context) does not fire this.
*/
onFailure?: (text: string) => void;
/**
* True when the lifecycle has discarded (or will discard) the in-flight
* compact — e.g. bound to the session compaction lifecycle's signal. A
Expand All @@ -434,10 +448,39 @@ export interface SessionPruningCompactorArgs {
compactionShape?: Partial<CompactionShape>;
}

function stubFallbackNoticeFromError(error: unknown): string | undefined {
const err = error instanceof Error ? error : new Error(String(error));
const kind = classifySummarizerFailure(err);
if (kind === "aborted") return undefined;
return summarizerStubFallbackNotice(kind, err);
}

function stubFallbackNoticeFromRecord(
pending: string | undefined,
kind: unknown,
): string {
if (pending !== undefined) return pending;
const label = typeof kind === "string" && kind.length > 0 ? kind : "failed";
return `Compaction summary failed — using a statistics-only stub (${label})`;
}

/** Shared pruning-compactor defaults for the main session agent. */
export function createSessionPruningCompactor(
args: SessionPruningCompactorArgs,
): Compactor {
let pendingStubNotice: string | undefined;
const innerSummarize = args.summarize;
const summarize =
innerSummarize === undefined
? undefined
: async (turns: ConversationTurn[], ctx?: SummaryContext) => {
try {
return await innerSummarize(turns, ctx);
} catch (error) {
pendingStubNotice = stubFallbackNoticeFromError(error);
throw error;
}
};
const compactor = createPruningCompactor({
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
// CL-9489 budgeted-tail shape: explicit defaults (same object the record
Expand All @@ -447,7 +490,7 @@ export function createSessionPruningCompactor(
...DEFAULT_TAIL_COMPACTION_SHAPE,
...args.compactionShape,
},
...(args.summarize !== undefined ? { summarize: args.summarize } : {}),
...(summarize !== undefined ? { summarize } : {}),
...(args.summaryContext ? { summaryContext: args.summaryContext } : {}),
...(args.readPriorHandoff !== undefined
? { readPriorHandoff: args.readPriorHandoff }
Expand All @@ -457,6 +500,7 @@ export function createSessionPruningCompactor(
return {
...compactor,
async apply(turns, ctx) {
pendingStubNotice = undefined;
const turnsBefore = turns.length;
const startedAt = Date.now();
const result = await compactor.apply(turns, ctx);
Expand All @@ -473,21 +517,33 @@ export function createSessionPruningCompactor(
// summarizedTurnCount is only set on the branch that actually folded
// turns away. The other branch is a no-op (or image aging alone), and
// reporting it as compaction would drag the duration and turn-count
// averages toward the runs where nothing happened. A statistics-only
// stub fold is not a successful LLM reduction: the operator notice
// owns that path, and emitting the success event would relabel it.
if (
result.record.decisions.summarizedTurnCount !== undefined &&
result.record.decisions.summarizeFailed !== 1
) {
telemetry.capture("compaction", {
mode: "llm",
duration_ms: Date.now() - startedAt,
turns_before: turnsBefore,
turns_after: result.output.length,
});
args.onFolded?.({ turnsBefore, turnsAfter: result.output.length });
// averages toward the runs where nothing happened.
if (result.record.decisions.summarizedTurnCount === undefined) {
return result;
}
const stub = result.record.decisions.summarizeFailed === 1;
// Stub folds still break the cached prefix, so prune still runs.
// Success telemetry and the TUI fold flash must not relabel a stub.
args.onFolded?.({
turnsBefore,
turnsAfter: result.output.length,
stub,
});
if (stub) {
args.onFailure?.(
stubFallbackNoticeFromRecord(
pendingStubNotice,
result.record.decisions.summarizeFailureKind,
),
);
return result;
}
telemetry.capture("compaction", {
mode: "llm",
duration_ms: Date.now() - startedAt,
turns_before: turnsBefore,
turns_after: result.output.length,
});
return result;
},
};
Expand Down
Loading
Loading