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
35 changes: 16 additions & 19 deletions e2e/integration-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,26 +348,23 @@ export async function openIntegrationSession(
...(opts.compactionCompletion !== undefined && primaryArchive !== undefined
? {
compactors: {
"pruning-compactor": wrapCompactorWithCompletenessGate(
createSessionPruningCompactor({
// Absent compactionShape falls back to the shared production
// default inside createSessionPruningCompactor.
...(opts.compactionShape !== undefined
? { compactionShape: opts.compactionShape }
: {}),
summarize: createModelSummarizer({
getSource: () => INTEGRATION_SOURCE,
deps: harness.deps,
complete: opts.compactionCompletion,
getArchive: () => evidenceArchiveHolder.current,
}),
readPriorHandoff: () =>
tryReadPriorHandoffFile((key) =>
storageForAgent.readBlob(key),
),
"pruning-compactor": createSessionPruningCompactor({
// Absent compactionShape falls back to the shared production
// default inside createSessionPruningCompactor.
...(opts.compactionShape !== undefined
? { compactionShape: opts.compactionShape }
: {}),
summarize: createModelSummarizer({
getSource: () => INTEGRATION_SOURCE,
deps: harness.deps,
complete: opts.compactionCompletion,
getArchive: () => evidenceArchiveHolder.current,
}),
primaryArchive,
),
readPriorHandoff: () =>
tryReadPriorHandoffFile((key) => storageForAgent.readBlob(key)),
wrapPruning: (pruning) =>
wrapCompactorWithCompletenessGate(pruning, primaryArchive),
}),
},
}
: {}),
Expand Down
3 changes: 2 additions & 1 deletion src/exec/runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -931,7 +931,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
getDefaultSource: () =>
liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id,
anthropicCachePrompt: () => config.anthropicCachePrompt,
getCompactor: () =>
getCompactor: (wrapPruning) =>
createSessionPruningCompactor({
summarize: summarizeForCompaction,
summaryContext: () => {
Expand Down Expand Up @@ -965,6 +965,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
},
});
},
...(wrapPruning !== undefined ? { wrapPruning } : {}),
}),
getCacheWriteSeed: () =>
resumeCacheWriteSeed({
Expand Down
19 changes: 11 additions & 8 deletions src/session/assemble-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -550,8 +550,12 @@ export interface ChatAgentWiring {
inferenceDeps: Awaited<ReturnType<typeof createInferenceDependencies>>;
getSources: () => InferenceSource[];
getDefaultSource: () => string;
/** Read at each build so a compaction-mode toggle is visible on rebuild. */
getCompactor: () => Compactor;
/**
* Read at each build so a compaction-mode toggle is visible on rebuild.
* assemble passes the completeness gate as `wrapPruning` so fold-commit
* side effects (stub notice, onFolded prune) run only after a fold lands.
*/
getCompactor: (wrapPruning?: (pruning: Compactor) => Compactor) => Compactor;
/** Experimental Anthropic prompt shrink. Default off when omitted. */
anthropicCachePrompt?: () => boolean;
/**
Expand Down Expand Up @@ -778,13 +782,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
defaultId: `${ID_PREFIX}/chat`,
}),
compactors: {
"pruning-compactor":
"pruning-compactor": wiring.getCompactor(
primaryArchive === undefined
? wiring.getCompactor()
: wrapCompactorWithCompletenessGate(
wiring.getCompactor(),
primaryArchive,
),
? undefined
: (pruning) =>
wrapCompactorWithCompletenessGate(pruning, primaryArchive),
),
},
});
const seed = wiring.getCacheWriteSeed?.();
Expand Down
147 changes: 146 additions & 1 deletion src/session/runtime-assembly.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { getLogger } from "@intx/log";
import type { ToolCall } from "@intx/types/runtime";
import type { ConversationTurn, ToolCall } from "@intx/types/runtime";

import { LOG_NAMESPACE_ROOT } from "../branding.js";
import * as permissionStore from "../permission/store.js";
Expand All @@ -33,6 +33,10 @@ import type { SubAgentSourcesConfig } from "./runtime-assembly.js";
import type { Settings } from "../config/settings.js";
import type { Telemetry } from "../telemetry/index.js";
import { createModelSummarizer } from "./summarizer.js";
import {
createCompactionArchive,
wrapCompactorWithCompletenessGate,
} from "./compaction-archive.js";
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
import type { PluginModule } from "../plugins/loader.js";

Expand Down Expand Up @@ -732,6 +736,147 @@ describe("createSessionPruningCompactor stub fallback", () => {
expect(folds).toEqual([]);
expect(notices).toEqual([]);
});

test("completeness-gate discard after a stub fold fires no notice or onFolded", async () => {
const notices: string[] = [];
const folds: { stub: boolean }[] = [];
const captured: { event: string }[] = [];
const telemetry: Telemetry = {
enabled: true,
installationId: "test",
capture: (event) => {
captured.push({ event });
},
captureIntentional: () => false,
flush: async () => undefined,
discard: () => undefined,
};
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 dir = await mkdtemp(join(tmpdir(), "compaction-gate-stub-"));
const blobs = new Map<string, Uint8Array>();
const archive = createCompactionArchive({
sessionId: "sess-gate-stub",
contextDir: dir,
writeBlob: async (key, bytes) => {
blobs.set(key, bytes);
},
readBlob: async (key) => {
const bytes = blobs.get(key);
if (bytes === undefined) throw new Error(`missing ${key}`);
return bytes;
},
});
const now = Date.now();
const many: ConversationTurn[] = Array.from({ length: 8 }, (_, i) => ({
role: i % 2 === 0 ? "user" : "assistant",
content: [{ type: "text", text: `t${i}` }],
timestamp: now,
}));
const result = await createSessionPruningCompactor({
summarize,
telemetry,
onFolded: (info) => folds.push(info),
onFailure: (text) => notices.push(text),
compactionShape: { tailBudgetTokens: 1 },
wrapPruning: (pruning) =>
wrapCompactorWithCompletenessGate(pruning, archive),
}).apply(many, { state: {} as never, trigger: "test" });
expect(result.output).toBe(many);
expect(result.record.reason).toBe("incomplete-evidence-archive");
expect(result.record.decisions.summarizedTurnCount).toBeUndefined();
expect(folds).toEqual([]);
expect(notices).toEqual([]);
expect(captured).toEqual([]);
});

test("a committed stub fold still notices and prunes", async () => {
const notices: string[] = [];
const folds: { turnsBefore: number; turnsAfter: number; stub: boolean }[] =
[];
const captured: { event: string }[] = [];
const telemetry: Telemetry = {
enabled: true,
installationId: "test",
capture: (event) => {
captured.push({ event });
},
captureIntentional: () => false,
flush: async () => undefined,
discard: () => undefined,
};
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 dir = await mkdtemp(join(tmpdir(), "compaction-gate-stub-ok-"));
const blobs = new Map<string, Uint8Array>();
const archive = createCompactionArchive({
sessionId: "sess-gate-stub-ok",
contextDir: dir,
writeBlob: async (key, bytes) => {
blobs.set(key, bytes);
},
readBlob: async (key) => {
const bytes = blobs.get(key);
if (bytes === undefined) throw new Error(`missing ${key}`);
return bytes;
},
});
const now = Date.now();
const many = Array.from({ length: 8 }, (_, i) => ({
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
content: [{ type: "text" as const, text: `t${i}` }],
timestamp: now,
}));
for (const turn of many) {
const block = turn.content[0];
if (block?.type !== "text") continue;
await archive.recordAuthorizedPayload({
kind: turn.role === "assistant" ? "assistant_text" : "user_message",
payload: block.text,
});
}
const result = await createSessionPruningCompactor({
summarize,
telemetry,
onFolded: (info) => folds.push(info),
onFailure: (text) => notices.push(text),
compactionShape: { tailBudgetTokens: 1 },
wrapPruning: (pruning) =>
wrapCompactorWithCompletenessGate(pruning, archive),
}).apply(many as never, { state: {} as never, trigger: "test" });
expect(result.record.decisions.summarizeFailed).toBe(1);
expect(result.record.reason).toContain("statistics-only stub");
expect(result.output).not.toBe(many);
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");
});
});

describe("buildCompactionContinuationMessage", () => {
Expand Down
14 changes: 12 additions & 2 deletions src/session/runtime-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,7 +430,8 @@ export interface SessionPruningCompactorArgs {
}) => void;
/**
* Operator-visible notice for a statistics-only stub that actually replaced
* turns. Verify abort (keeping prior context) does not fire this.
* turns. Verify abort and completeness-gate discard (keeping prior context)
* do not fire this.
*/
onFailure?: (text: string) => void;
/**
Expand All @@ -440,6 +441,13 @@ export interface SessionPruningCompactorArgs {
* no onFolded side effects for work that never landed.
*/
isAborted?: () => boolean;
/**
* Wraps the inner pruning apply before fold-commit side effects.
* assembleChatAgent passes the completeness gate here so a discarded
* fold never notices or prunes.
*/
wrapPruning?: (pruning: Compactor) => Compactor;

/**
* CL-9489 budgeted-tail shape override. Absent means the shared production
* default (DEFAULT_TAIL_COMPACTION_SHAPE); tests pin a tiny budget so small
Expand Down Expand Up @@ -481,7 +489,7 @@ export function createSessionPruningCompactor(
throw error;
}
};
const compactor = createPruningCompactor({
const pruning = createPruningCompactor({
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
// CL-9489 budgeted-tail shape: explicit defaults (same object the record
// carries under parameters.compactionShape). Zero recent turns stay whole
Expand All @@ -496,6 +504,8 @@ export function createSessionPruningCompactor(
? { readPriorHandoff: args.readPriorHandoff }
: {}),
});
const compactor = args.wrapPruning?.(pruning) ?? pruning;

const telemetry = args.telemetry ?? NOOP_TELEMETRY;
return {
...compactor,
Expand Down
6 changes: 4 additions & 2 deletions src/tui/runner/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -697,7 +697,7 @@ export async function assembleTUISession(
? state.liveDefaultSource
: state.liveSource.id,
anthropicCachePrompt: () => config.anthropicCachePrompt,
getCompactor: () =>
getCompactor: (wrapPruning) =>
compactionLifecycle.wrapCompactor(
createSessionPruningCompactor({
summarize: compactionSummarize,
Expand All @@ -711,7 +711,8 @@ export async function assembleTUISession(
// still completes underneath must not report telemetry or side
// effects for work that never landed.
isAborted: () => compactionLifecycle.getSignal().aborted,
// Stub notice waits until the fold commits (verify abort stays silent).
// Stub notice waits until the fold commits (verify abort and
// completeness-gate discard stay silent).
onFailure: (text) => state.systemNotice?.(text),
// Main-session folds only — exec runner and subagents stay silent.
onFolded: (info) => {
Expand All @@ -731,6 +732,7 @@ export async function assembleTUISession(
});
if (!info.stub) emitter.emit("compaction", info);
},
...(wrapPruning !== undefined ? { wrapPruning } : {}),
}),
),
getCacheWriteSeed: () =>
Expand Down
Loading