Skip to content

Commit 8dff48f

Browse files
committed
fix(session): defer stub notice until after the completeness gate
The completeness gate can still return the original turns after an inner stub fold. Firing the operator notice and onFolded prune on the inner result described a fold that never landed.
1 parent 4832427 commit 8dff48f

6 files changed

Lines changed: 191 additions & 33 deletions

File tree

‎e2e/integration-harness.ts‎

Lines changed: 16 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -348,26 +348,23 @@ export async function openIntegrationSession(
348348
...(opts.compactionCompletion !== undefined && primaryArchive !== undefined
349349
? {
350350
compactors: {
351-
"pruning-compactor": wrapCompactorWithCompletenessGate(
352-
createSessionPruningCompactor({
353-
// Absent compactionShape falls back to the shared production
354-
// default inside createSessionPruningCompactor.
355-
...(opts.compactionShape !== undefined
356-
? { compactionShape: opts.compactionShape }
357-
: {}),
358-
summarize: createModelSummarizer({
359-
getSource: () => INTEGRATION_SOURCE,
360-
deps: harness.deps,
361-
complete: opts.compactionCompletion,
362-
getArchive: () => evidenceArchiveHolder.current,
363-
}),
364-
readPriorHandoff: () =>
365-
tryReadPriorHandoffFile((key) =>
366-
storageForAgent.readBlob(key),
367-
),
351+
"pruning-compactor": createSessionPruningCompactor({
352+
// Absent compactionShape falls back to the shared production
353+
// default inside createSessionPruningCompactor.
354+
...(opts.compactionShape !== undefined
355+
? { compactionShape: opts.compactionShape }
356+
: {}),
357+
summarize: createModelSummarizer({
358+
getSource: () => INTEGRATION_SOURCE,
359+
deps: harness.deps,
360+
complete: opts.compactionCompletion,
361+
getArchive: () => evidenceArchiveHolder.current,
368362
}),
369-
primaryArchive,
370-
),
363+
readPriorHandoff: () =>
364+
tryReadPriorHandoffFile((key) => storageForAgent.readBlob(key)),
365+
wrapPruning: (pruning) =>
366+
wrapCompactorWithCompletenessGate(pruning, primaryArchive),
367+
}),
371368
},
372369
}
373370
: {}),

‎src/exec/runner.ts‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -931,7 +931,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
931931
getDefaultSource: () =>
932932
liveDefaultSource.length > 0 ? liveDefaultSource : liveSource.id,
933933
anthropicCachePrompt: () => config.anthropicCachePrompt,
934-
getCompactor: () =>
934+
getCompactor: (wrapPruning) =>
935935
createSessionPruningCompactor({
936936
summarize: summarizeForCompaction,
937937
summaryContext: () => {
@@ -965,6 +965,7 @@ export async function runExec(config: Config): Promise<ExecResult> {
965965
},
966966
});
967967
},
968+
...(wrapPruning !== undefined ? { wrapPruning } : {}),
968969
}),
969970
getCacheWriteSeed: () =>
970971
resumeCacheWriteSeed({

‎src/session/assemble-runtime.ts‎

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -550,8 +550,12 @@ export interface ChatAgentWiring {
550550
inferenceDeps: Awaited<ReturnType<typeof createInferenceDependencies>>;
551551
getSources: () => InferenceSource[];
552552
getDefaultSource: () => string;
553-
/** Read at each build so a compaction-mode toggle is visible on rebuild. */
554-
getCompactor: () => Compactor;
553+
/**
554+
* Read at each build so a compaction-mode toggle is visible on rebuild.
555+
* assemble passes the completeness gate as `wrapPruning` so fold-commit
556+
* side effects (stub notice, onFolded prune) run only after a fold lands.
557+
*/
558+
getCompactor: (wrapPruning?: (pruning: Compactor) => Compactor) => Compactor;
555559
/** Experimental Anthropic prompt shrink. Default off when omitted. */
556560
anthropicCachePrompt?: () => boolean;
557561
/**
@@ -778,13 +782,12 @@ export function assembleChatAgent(wiring: ChatAgentWiring): AssembledChatAgent {
778782
defaultId: `${ID_PREFIX}/chat`,
779783
}),
780784
compactors: {
781-
"pruning-compactor":
785+
"pruning-compactor": wiring.getCompactor(
782786
primaryArchive === undefined
783-
? wiring.getCompactor()
784-
: wrapCompactorWithCompletenessGate(
785-
wiring.getCompactor(),
786-
primaryArchive,
787-
),
787+
? undefined
788+
: (pruning) =>
789+
wrapCompactorWithCompletenessGate(pruning, primaryArchive),
790+
),
788791
},
789792
});
790793
const seed = wiring.getCacheWriteSeed?.();

‎src/session/runtime-assembly.test.ts‎

Lines changed: 146 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
1111
import { tmpdir } from "node:os";
1212
import { join } from "node:path";
1313
import { getLogger } from "@intx/log";
14-
import type { ToolCall } from "@intx/types/runtime";
14+
import type { ConversationTurn, ToolCall } from "@intx/types/runtime";
1515

1616
import { LOG_NAMESPACE_ROOT } from "../branding.js";
1717
import * as permissionStore from "../permission/store.js";
@@ -33,6 +33,10 @@ import type { SubAgentSourcesConfig } from "./runtime-assembly.js";
3333
import type { Settings } from "../config/settings.js";
3434
import type { Telemetry } from "../telemetry/index.js";
3535
import { createModelSummarizer } from "./summarizer.js";
36+
import {
37+
createCompactionArchive,
38+
wrapCompactorWithCompletenessGate,
39+
} from "./compaction-archive.js";
3640
import { generateSessionId, initSessionDir, sessionDir } from "./index.js";
3741
import type { PluginModule } from "../plugins/loader.js";
3842

@@ -732,6 +736,147 @@ describe("createSessionPruningCompactor stub fallback", () => {
732736
expect(folds).toEqual([]);
733737
expect(notices).toEqual([]);
734738
});
739+
740+
test("completeness-gate discard after a stub fold fires no notice or onFolded", async () => {
741+
const notices: string[] = [];
742+
const folds: { stub: boolean }[] = [];
743+
const captured: { event: string }[] = [];
744+
const telemetry: Telemetry = {
745+
enabled: true,
746+
installationId: "test",
747+
capture: (event) => {
748+
captured.push({ event });
749+
},
750+
captureIntentional: () => false,
751+
flush: async () => undefined,
752+
discard: () => undefined,
753+
};
754+
const summarize = createModelSummarizer({
755+
getSource: () =>
756+
({
757+
id: "test",
758+
provider: "openai",
759+
model: "test-model",
760+
baseURL: "http://localhost:1",
761+
credentialId: "test",
762+
}) as never,
763+
complete: async () => {
764+
throw new Error("model unreachable");
765+
},
766+
});
767+
const dir = await mkdtemp(join(tmpdir(), "compaction-gate-stub-"));
768+
const blobs = new Map<string, Uint8Array>();
769+
const archive = createCompactionArchive({
770+
sessionId: "sess-gate-stub",
771+
contextDir: dir,
772+
writeBlob: async (key, bytes) => {
773+
blobs.set(key, bytes);
774+
},
775+
readBlob: async (key) => {
776+
const bytes = blobs.get(key);
777+
if (bytes === undefined) throw new Error(`missing ${key}`);
778+
return bytes;
779+
},
780+
});
781+
const now = Date.now();
782+
const many: ConversationTurn[] = Array.from({ length: 8 }, (_, i) => ({
783+
role: i % 2 === 0 ? "user" : "assistant",
784+
content: [{ type: "text", text: `t${i}` }],
785+
timestamp: now,
786+
}));
787+
const result = await createSessionPruningCompactor({
788+
summarize,
789+
telemetry,
790+
onFolded: (info) => folds.push(info),
791+
onFailure: (text) => notices.push(text),
792+
compactionShape: { tailBudgetTokens: 1 },
793+
wrapPruning: (pruning) =>
794+
wrapCompactorWithCompletenessGate(pruning, archive),
795+
}).apply(many, { state: {} as never, trigger: "test" });
796+
expect(result.output).toBe(many);
797+
expect(result.record.reason).toBe("incomplete-evidence-archive");
798+
expect(result.record.decisions.summarizedTurnCount).toBeUndefined();
799+
expect(folds).toEqual([]);
800+
expect(notices).toEqual([]);
801+
expect(captured).toEqual([]);
802+
});
803+
804+
test("a committed stub fold still notices and prunes", async () => {
805+
const notices: string[] = [];
806+
const folds: { turnsBefore: number; turnsAfter: number; stub: boolean }[] =
807+
[];
808+
const captured: { event: string }[] = [];
809+
const telemetry: Telemetry = {
810+
enabled: true,
811+
installationId: "test",
812+
capture: (event) => {
813+
captured.push({ event });
814+
},
815+
captureIntentional: () => false,
816+
flush: async () => undefined,
817+
discard: () => undefined,
818+
};
819+
const summarize = createModelSummarizer({
820+
getSource: () =>
821+
({
822+
id: "test",
823+
provider: "openai",
824+
model: "test-model",
825+
baseURL: "http://localhost:1",
826+
credentialId: "test",
827+
}) as never,
828+
complete: async () => {
829+
throw new Error("model unreachable");
830+
},
831+
});
832+
const dir = await mkdtemp(join(tmpdir(), "compaction-gate-stub-ok-"));
833+
const blobs = new Map<string, Uint8Array>();
834+
const archive = createCompactionArchive({
835+
sessionId: "sess-gate-stub-ok",
836+
contextDir: dir,
837+
writeBlob: async (key, bytes) => {
838+
blobs.set(key, bytes);
839+
},
840+
readBlob: async (key) => {
841+
const bytes = blobs.get(key);
842+
if (bytes === undefined) throw new Error(`missing ${key}`);
843+
return bytes;
844+
},
845+
});
846+
const now = Date.now();
847+
const many = Array.from({ length: 8 }, (_, i) => ({
848+
role: (i % 2 === 0 ? "user" : "assistant") as "user" | "assistant",
849+
content: [{ type: "text" as const, text: `t${i}` }],
850+
timestamp: now,
851+
}));
852+
for (const turn of many) {
853+
const block = turn.content[0];
854+
if (block?.type !== "text") continue;
855+
await archive.recordAuthorizedPayload({
856+
kind: turn.role === "assistant" ? "assistant_text" : "user_message",
857+
payload: block.text,
858+
});
859+
}
860+
const result = await createSessionPruningCompactor({
861+
summarize,
862+
telemetry,
863+
onFolded: (info) => folds.push(info),
864+
onFailure: (text) => notices.push(text),
865+
compactionShape: { tailBudgetTokens: 1 },
866+
wrapPruning: (pruning) =>
867+
wrapCompactorWithCompletenessGate(pruning, archive),
868+
}).apply(many as never, { state: {} as never, trigger: "test" });
869+
expect(result.record.decisions.summarizeFailed).toBe(1);
870+
expect(result.record.reason).toContain("statistics-only stub");
871+
expect(result.output).not.toBe(many);
872+
expect(folds).toEqual([
873+
{ turnsBefore: 8, turnsAfter: result.output.length, stub: true },
874+
]);
875+
expect(captured).toEqual([]);
876+
expect(notices).toHaveLength(1);
877+
expect(notices[0]).toContain("statistics-only stub");
878+
expect(notices[0]).toContain("failed");
879+
});
735880
});
736881

737882
describe("buildCompactionContinuationMessage", () => {

‎src/session/runtime-assembly.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,8 @@ export interface SessionPruningCompactorArgs {
430430
}) => void;
431431
/**
432432
* Operator-visible notice for a statistics-only stub that actually replaced
433-
* turns. Verify abort (keeping prior context) does not fire this.
433+
* turns. Verify abort and completeness-gate discard (keeping prior context)
434+
* do not fire this.
434435
*/
435436
onFailure?: (text: string) => void;
436437
/**
@@ -440,6 +441,13 @@ export interface SessionPruningCompactorArgs {
440441
* no onFolded side effects for work that never landed.
441442
*/
442443
isAborted?: () => boolean;
444+
/**
445+
* Wraps the inner pruning apply before fold-commit side effects.
446+
* assembleChatAgent passes the completeness gate here so a discarded
447+
* fold never notices or prunes.
448+
*/
449+
wrapPruning?: (pruning: Compactor) => Compactor;
450+
443451
/**
444452
* CL-9489 budgeted-tail shape override. Absent means the shared production
445453
* default (DEFAULT_TAIL_COMPACTION_SHAPE); tests pin a tiny budget so small
@@ -481,7 +489,7 @@ export function createSessionPruningCompactor(
481489
throw error;
482490
}
483491
};
484-
const compactor = createPruningCompactor({
492+
const pruning = createPruningCompactor({
485493
summaryMaxChars: SESSION_COMPACTOR_SUMMARY_MAX_CHARS,
486494
// CL-9489 budgeted-tail shape: explicit defaults (same object the record
487495
// carries under parameters.compactionShape). Zero recent turns stay whole
@@ -496,6 +504,8 @@ export function createSessionPruningCompactor(
496504
? { readPriorHandoff: args.readPriorHandoff }
497505
: {}),
498506
});
507+
const compactor = args.wrapPruning?.(pruning) ?? pruning;
508+
499509
const telemetry = args.telemetry ?? NOOP_TELEMETRY;
500510
return {
501511
...compactor,

‎src/tui/runner/session.ts‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,7 +697,7 @@ export async function assembleTUISession(
697697
? state.liveDefaultSource
698698
: state.liveSource.id,
699699
anthropicCachePrompt: () => config.anthropicCachePrompt,
700-
getCompactor: () =>
700+
getCompactor: (wrapPruning) =>
701701
compactionLifecycle.wrapCompactor(
702702
createSessionPruningCompactor({
703703
summarize: compactionSummarize,
@@ -711,7 +711,8 @@ export async function assembleTUISession(
711711
// still completes underneath must not report telemetry or side
712712
// effects for work that never landed.
713713
isAborted: () => compactionLifecycle.getSignal().aborted,
714-
// Stub notice waits until the fold commits (verify abort stays silent).
714+
// Stub notice waits until the fold commits (verify abort and
715+
// completeness-gate discard stay silent).
715716
onFailure: (text) => state.systemNotice?.(text),
716717
// Main-session folds only — exec runner and subagents stay silent.
717718
onFolded: (info) => {
@@ -731,6 +732,7 @@ export async function assembleTUISession(
731732
});
732733
if (!info.stub) emitter.emit("compaction", info);
733734
},
735+
...(wrapPruning !== undefined ? { wrapPruning } : {}),
734736
}),
735737
),
736738
getCacheWriteSeed: () =>

0 commit comments

Comments
 (0)