fix(compaction): honor /clear reset boundary in prepareCompaction

prepareCompaction walked the branch from the last compaction and ignored reset_boundary markers, so /compact (and auto-compaction) resurrected pre-/clear turns into the summary even though buildSessionContext already starts the model context after the boundary.

Model reset_boundary as a first-class agent-core session entry and start the summarization window after the latest boundary, dropping the superseded pre-reset compaction summary. A boundary before the last compaction stays superseded by it.

Fixes #8718
This commit is contained in:
roboomp
2026-08-16 11:37:13 +00:00
parent 37eee71978
commit 722c4aa0ae
5 changed files with 137 additions and 2 deletions
+4
View File
@@ -2,6 +2,10 @@
## [Unreleased]
### Fixed
- Fixed `/compact` (and automatic compaction) resurrecting pre-`/clear` conversation turns: `prepareCompaction` now honors the latest `reset_boundary`, so a compaction after an in-place `/clear` only summarizes messages created after the reset ([#8718](https://github.com/can1357/oh-my-pi/issues/8718)).
## [17.3.5] - 2026-08-16
### Added
+20 -1
View File
@@ -1271,7 +1271,26 @@ export function prepareCompaction(
prevCompactionIndex = i;
break;
}
const boundaryStart = prevCompactionIndex + 1;
// Honor the latest `/clear` reset boundary. `/clear` records a
// `reset_boundary` marker and reports the model context empty, so compaction
// must not resurrect the dropped pre-clear turns into its summary — matching
// how buildSessionContext starts the model-context rebuild after the boundary.
// A boundary after the last reusable compaction supersedes it: the pre-reset
// summary was cleared too, so drop the previous-compaction reuse and start
// fresh after the boundary. A boundary at or before that compaction is already
// superseded by it, so only scan newer entries.
let resetBoundaryIndex = -1;
for (let i = pathEntries.length - 1; i > prevCompactionIndex; i--) {
if (pathEntries[i].type === "reset_boundary") {
resetBoundaryIndex = i;
break;
}
}
if (resetBoundaryIndex > prevCompactionIndex) {
prevCompactionIndex = -1;
}
const boundaryStart = Math.max(prevCompactionIndex, resetBoundaryIndex) + 1;
const boundaryEnd = pathEntries.length;
const lastUsage = getLastAssistantUsage(pathEntries);
+11
View File
@@ -117,6 +117,16 @@ export interface ModeChangeEntry extends SessionEntryBase {
data?: Record<string, unknown>;
}
/**
* Durable context-reset marker recorded by an in-place `/clear`. It carries no
* payload — its presence on the branch means every entry before it was dropped
* from the model context, so context assembly and compaction start after the
* latest one. The full pre-reset history stays on disk for transcript export.
*/
export interface ResetBoundaryEntry extends SessionEntryBase {
type: "reset_boundary";
}
export interface CustomCompactionSessionEntries {}
export type SessionEntry =
@@ -133,6 +143,7 @@ export type SessionEntry =
| TtsrInjectionEntry
| SessionInitEntry
| ModeChangeEntry
| ResetBoundaryEntry
| CustomCompactionSessionEntries[keyof CustomCompactionSessionEntries];
export interface ReadonlySessionManager {
@@ -0,0 +1,102 @@
import { describe, expect, test } from "bun:test";
import {
type CompactionEntry,
DEFAULT_COMPACTION_SETTINGS,
prepareCompaction,
type SessionEntry,
} from "@oh-my-pi/pi-agent-core/compaction";
import { createAssistantMessage, createUserMessage } from "./helpers";
let seq = 0;
function base(type: string) {
const id = `e${seq++}`;
return { id, parentId: null, timestamp: new Date().toISOString(), type };
}
function userEntry(text: string): SessionEntry {
return { ...base("message"), type: "message", message: createUserMessage(text) };
}
function assistantEntry(text: string): SessionEntry {
return {
...base("message"),
type: "message",
message: createAssistantMessage([{ type: "text", text }]),
};
}
function resetBoundary(): SessionEntry {
return { ...base("reset_boundary"), type: "reset_boundary" };
}
function compaction(summary: string, firstKeptEntryId: string): SessionEntry {
const entry: CompactionEntry = {
...base("compaction"),
type: "compaction",
summary,
firstKeptEntryId,
tokensBefore: 0,
};
return entry;
}
describe("prepareCompaction reset boundary", () => {
test("does not resurrect pre-clear turns into the summary", () => {
const entries: SessionEntry[] = [
userEntry("PRECLEAR user request"),
assistantEntry("PRECLEAR assistant answer"),
resetBoundary(),
userEntry("POSTCLEAR first request"),
assistantEntry("POSTCLEAR first answer"),
userEntry("POSTCLEAR second request"),
assistantEntry("POSTCLEAR second answer"),
];
const prep = prepareCompaction(entries, { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 });
expect(prep).toBeDefined();
const summarized = JSON.stringify(prep?.messagesToSummarize ?? []);
const kept = JSON.stringify([...(prep?.turnPrefixMessages ?? []), ...(prep?.recentMessages ?? [])]);
expect(summarized).not.toContain("PRECLEAR");
expect(kept).not.toContain("PRECLEAR");
expect(summarized).toContain("POSTCLEAR first");
});
test("a reset boundary after the last compaction drops that compaction's summary reuse", () => {
const entries: SessionEntry[] = [
userEntry("OLD user"),
assistantEntry("OLD assistant"),
compaction("OLD SUMMARY", "kept-old"),
userEntry("MIDCLEAR user"),
assistantEntry("MIDCLEAR assistant"),
resetBoundary(),
userEntry("POST first"),
assistantEntry("POST first answer"),
userEntry("POST second"),
assistantEntry("POST second answer"),
];
const prep = prepareCompaction(entries, { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 });
expect(prep).toBeDefined();
// The cleared compaction is not reused as previous context.
expect(prep?.previousSummary).toBeUndefined();
const summarized = JSON.stringify(prep?.messagesToSummarize ?? []);
expect(summarized).not.toContain("MIDCLEAR");
expect(summarized).not.toContain("OLD");
expect(summarized).toContain("POST first");
});
test("a reset boundary before the last compaction is superseded by it", () => {
const entries: SessionEntry[] = [
userEntry("PRE user"),
resetBoundary(),
userEntry("MID user"),
assistantEntry("MID assistant"),
compaction("KEEP SUMMARY", "kept-mid"),
userEntry("TAIL one"),
assistantEntry("TAIL one answer"),
userEntry("TAIL two"),
assistantEntry("TAIL two answer"),
];
const prep = prepareCompaction(entries, { ...DEFAULT_COMPACTION_SETTINGS, keepRecentTokens: 1 });
expect(prep).toBeDefined();
// Compaction after the boundary wins: its summary is still reused.
expect(prep?.previousSummary).toBe("KEEP SUMMARY");
const summarized = JSON.stringify(prep?.messagesToSummarize ?? []);
expect(summarized).toContain("TAIL one");
expect(summarized).not.toContain("MID");
});
});
@@ -171,7 +171,6 @@ declare module "@oh-my-pi/pi-agent-core/compaction/entries" {
interface CustomCompactionSessionEntries {
titleChange: TitleChangeEntry;
credentialPin: CredentialPinEntry;
resetBoundary: ResetBoundaryEntry;
}
}