Skip to content

Commit f80ae24

Browse files
fix(session): defer stub notice until after the completeness gate (#1247)
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 f80ae24

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)