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:
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user