Skip to content

Commit 06c47e7

Browse files
committed
fix(compaction): preserve fresh usage after compaction
1 parent 549a0ea commit 06c47e7

2 files changed

Lines changed: 116 additions & 3 deletions

File tree

src/agents/pi-embedded-subscribe.handlers.compaction.test.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,19 +14,32 @@ import {
1414
reconcileSessionStoreCompactionCountAfterSuccess,
1515
} from "./pi-embedded-subscribe.handlers.compaction.js";
1616
import type { EmbeddedPiSubscribeContext } from "./pi-embedded-subscribe.handlers.types.js";
17+
import { makeZeroUsageSnapshot } from "./usage.js";
18+
19+
function makeUsage(input: number, output: number, totalTokens: number) {
20+
return {
21+
input,
22+
output,
23+
cacheRead: 0,
24+
cacheWrite: 0,
25+
totalTokens,
26+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
27+
};
28+
}
1729

1830
function createCompactionContext(params: {
1931
storePath: string;
2032
sessionKey: string;
2133
agentId?: string;
2234
initialCount: number;
2335
info?: (message: string, meta?: Record<string, unknown>) => void;
36+
messages?: unknown[];
2437
}): EmbeddedPiSubscribeContext {
2538
let compactionCount = params.initialCount;
2639
return {
2740
params: {
2841
runId: "run-test",
29-
session: { messages: [] } as never,
42+
session: { messages: params.messages ?? [] } as never,
3043
config: { session: { store: params.storePath } } as never,
3144
sessionKey: params.sessionKey,
3245
sessionId: "session-1",
@@ -306,4 +319,59 @@ describe("handleCompactionEnd", () => {
306319
expect(await readCompactionCount(storePath, sessionKey)).toBe(2);
307320
expect(ctx.noteCompactionTokensAfter).toHaveBeenCalledWith(undefined);
308321
});
322+
323+
it("keeps fresh assistant usage after the latest compaction summary", async () => {
324+
const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-compaction-usage-"));
325+
const storePath = path.join(tmp, "sessions.json");
326+
const sessionKey = "main";
327+
await seedSessionStore({
328+
storePath,
329+
sessionKey,
330+
compactionCount: 0,
331+
});
332+
const staleUsage = makeUsage(120_000, 3_000, 123_000);
333+
const freshUsage = makeUsage(1_000, 250, 1_250);
334+
const messages: Array<Record<string, unknown> & { usage?: unknown }> = [
335+
{
336+
role: "assistant",
337+
content: "pre-compaction answer",
338+
timestamp: "2026-03-20T00:00:00.000Z",
339+
usage: staleUsage,
340+
},
341+
{
342+
role: "compactionSummary",
343+
summary: "compressed",
344+
timestamp: "2026-03-20T00:00:10.000Z",
345+
},
346+
{
347+
role: "user",
348+
content: "new question",
349+
timestamp: "2026-03-20T00:00:20.000Z",
350+
},
351+
{
352+
role: "assistant",
353+
content: "fresh answer",
354+
timestamp: "2026-03-20T00:00:30.000Z",
355+
usage: freshUsage,
356+
},
357+
];
358+
359+
const ctx = createCompactionContext({
360+
storePath,
361+
sessionKey,
362+
initialCount: 0,
363+
messages,
364+
});
365+
366+
handleCompactionEnd(ctx, {
367+
type: "compaction_end",
368+
reason: "threshold",
369+
result: { kept: 12 },
370+
willRetry: false,
371+
aborted: false,
372+
});
373+
374+
expect(messages[0]?.usage).toEqual(makeZeroUsageSnapshot());
375+
expect(messages[3]?.usage).toEqual(freshUsage);
376+
});
309377
});

src/agents/pi-embedded-subscribe.handlers.compaction.ts

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -183,16 +183,61 @@ function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedPiSubscribeConte
183183
if (!Array.isArray(messages)) {
184184
return;
185185
}
186-
for (const message of messages) {
186+
187+
let latestCompactionSummaryIndex = -1;
188+
let latestCompactionTimestamp: number | null = null;
189+
for (let i = 0; i < messages.length; i += 1) {
190+
const entry = messages[i];
191+
if (!entry || typeof entry !== "object") {
192+
continue;
193+
}
194+
const candidate = entry as { role?: unknown; timestamp?: unknown };
195+
if (candidate.role !== "compactionSummary") {
196+
continue;
197+
}
198+
latestCompactionSummaryIndex = i;
199+
latestCompactionTimestamp = parseMessageTimestamp(candidate.timestamp);
200+
}
201+
202+
for (let i = 0; i < messages.length; i += 1) {
203+
const message = messages[i];
187204
if (!message || typeof message !== "object") {
188205
continue;
189206
}
190-
const candidate = message as { role?: unknown; usage?: unknown };
207+
const candidate = message as {
208+
role?: unknown;
209+
timestamp?: unknown;
210+
usage?: unknown;
211+
};
191212
if (candidate.role !== "assistant") {
192213
continue;
193214
}
215+
const staleByLegacyNoSummary = latestCompactionSummaryIndex === -1;
216+
const messageTimestamp = parseMessageTimestamp(candidate.timestamp);
217+
const staleByTimestamp =
218+
latestCompactionTimestamp !== null &&
219+
messageTimestamp !== null &&
220+
messageTimestamp <= latestCompactionTimestamp;
221+
const staleByLegacyOrdering =
222+
latestCompactionSummaryIndex !== -1 && i < latestCompactionSummaryIndex;
223+
if (!staleByLegacyNoSummary && !staleByTimestamp && !staleByLegacyOrdering) {
224+
continue;
225+
}
194226
// pi-coding-agent expects assistant usage to exist when computing context usage.
195227
// Reset stale snapshots to zeros instead of deleting the field.
196228
candidate.usage = makeZeroUsageSnapshot();
197229
}
198230
}
231+
232+
function parseMessageTimestamp(value: unknown): number | null {
233+
if (typeof value === "number" && Number.isFinite(value)) {
234+
return value;
235+
}
236+
if (typeof value === "string") {
237+
const parsed = Date.parse(value);
238+
if (Number.isFinite(parsed)) {
239+
return parsed;
240+
}
241+
}
242+
return null;
243+
}

0 commit comments

Comments
 (0)