Skip to content

Commit 96fe4b2

Browse files
committed
fix: preserve post-compaction session token freshness (#82578)
1 parent a735d2e commit 96fe4b2

6 files changed

Lines changed: 66 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ Docs: https://docs.openclaw.ai
1717

1818
### Fixes
1919

20+
- Agents/sessions: preserve fresh post-compaction token snapshots across stale usage updates, preventing repeated auto-compaction after every message. Fixes #82576. (#82578) Thanks @njuboy11.
2021
- Gateway/WebChat: route image attachments through a configured vision-capable `imageModel` plan before inlining images, and carry that image-model fallback chain through runtime retries. (#82524) Thanks @frankekn.
2122
- Codex app-server: limit canonical OpenAI Codex app-server attribution rewrites to local transcript and trajectory records, leaving runtime/tool routing on the selected OpenAI model metadata so OpenAI API-key backup profiles keep their billing path.
2223
- Android/chat: make bare and markdown URLs in chat messages tappable by preserving Compose URL annotations in rendered markdown. Fixes #82187. (#82392) Thanks @neeravmakwana.

src/auto-reply/reply/agent-runner.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1614,6 +1614,7 @@ export async function runReplyAgent(params: {
16141614
systemPromptReport: runResult.meta?.systemPromptReport,
16151615
cliSessionId,
16161616
cliSessionBinding,
1617+
preserveFreshTotalTokensOnStaleUsage: preflightCompactionApplied,
16171618
});
16181619

16191620
const returnSilentFallbackFailureIfNeeded = async (): Promise<ReplyPayload | undefined> => {

src/auto-reply/reply/reply-state.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -606,7 +606,7 @@ describe("incrementCompactionCount", () => {
606606
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
607607
expect(stored[sessionKey].compactionCount).toBe(1);
608608
expect(stored[sessionKey].totalTokens).toBe(180_000);
609-
expect(stored[sessionKey].totalTokensFresh).toBe(true);
609+
expect(stored[sessionKey].totalTokensFresh).toBe(false);
610610
});
611611

612612
it("updates sessionId and sessionFile when compaction rotated transcripts", async () => {
@@ -732,12 +732,13 @@ describe("incrementCompactionCount", () => {
732732
expect(stored[sessionKey].compactionCount).toBe(1);
733733
});
734734

735-
it("does not update totalTokens when tokensAfter is not provided", async () => {
735+
it("marks totalTokens stale when tokensAfter is not provided", async () => {
736736
const entry = {
737737
sessionId: "s1",
738738
updatedAt: Date.now(),
739739
compactionCount: 0,
740740
totalTokens: 180_000,
741+
totalTokensFresh: true,
741742
} as SessionEntry;
742743
const { storePath, sessionKey, sessionStore } = await createCompactionSessionFixture(entry);
743744

@@ -752,5 +753,6 @@ describe("incrementCompactionCount", () => {
752753
expect(stored[sessionKey].compactionCount).toBe(1);
753754
// totalTokens unchanged
754755
expect(stored[sessionKey].totalTokens).toBe(180_000);
756+
expect(stored[sessionKey].totalTokensFresh).toBe(false);
755757
});
756758
});

src/auto-reply/reply/session-updates.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,8 @@ export async function incrementCompactionCount(params: {
420420
updates.outputTokens = undefined;
421421
updates.cacheRead = undefined;
422422
updates.cacheWrite = undefined;
423+
} else if (incrementBy > 0) {
424+
updates.totalTokensFresh = false;
423425
}
424426
sessionStore[sessionKey] = {
425427
...entry,

src/auto-reply/reply/session-usage.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@ export async function persistSessionUsageUpdate(params: {
8888
systemPromptReport?: SessionSystemPromptReport;
8989
cliSessionId?: string;
9090
cliSessionBinding?: import("../../config/sessions.js").CliSessionBinding;
91+
preserveFreshTotalTokensOnStaleUsage?: boolean;
9192
logLabel?: string;
9293
}): Promise<void> {
9394
const { storePath, sessionKey } = params;
@@ -154,15 +155,13 @@ export async function persistSessionUsageUpdate(params: {
154155
if (runEstimatedCostUsd !== undefined) {
155156
patch.estimatedCostUsd = runEstimatedCostUsd;
156157
}
157-
// Only update totalTokens value when we have a fresh context snapshot
158-
// (lastCallUsage or promptTokens or usageIsContextSnapshot).
159-
// When the snapshot is stale we keep the existing totalTokens so that
160-
// preflight compaction guards set by incrementCompactionCount are not
161-
// corrupted by a stale usage update, but mark it as stale.
162158
if (hasFreshContextSnapshot) {
163159
patch.totalTokens = totalTokens;
164160
patch.totalTokensFresh = true;
165-
} else {
161+
} else if (
162+
params.preserveFreshTotalTokensOnStaleUsage !== true ||
163+
entry.totalTokensFresh !== true
164+
) {
166165
patch.totalTokensFresh = false;
167166
}
168167
return applyCliSessionIdToSessionPatch(params, entry, patch);

src/auto-reply/reply/session.test.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3054,6 +3054,59 @@ describe("persistSessionUsageUpdate", () => {
30543054
expect(stored[sessionKey].totalTokensFresh).toBe(false);
30553055
});
30563056

3057+
it("preserves fresh post-compaction totalTokens across stale usage updates", async () => {
3058+
const storePath = await createStorePath("openclaw-usage-");
3059+
const sessionKey = "main";
3060+
await seedSessionStore({
3061+
storePath,
3062+
sessionKey,
3063+
entry: {
3064+
sessionId: "s1",
3065+
updatedAt: Date.now(),
3066+
totalTokens: 42_000,
3067+
totalTokensFresh: true,
3068+
},
3069+
});
3070+
3071+
await persistSessionUsageUpdate({
3072+
storePath,
3073+
sessionKey,
3074+
usage: { input: 50_000, output: 5_000, total: 55_000 },
3075+
contextTokensUsed: 200_000,
3076+
preserveFreshTotalTokensOnStaleUsage: true,
3077+
});
3078+
3079+
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
3080+
expect(stored[sessionKey].totalTokens).toBe(42_000);
3081+
expect(stored[sessionKey].totalTokensFresh).toBe(true);
3082+
});
3083+
3084+
it("marks older fresh totalTokens stale when no compaction preservation is requested", async () => {
3085+
const storePath = await createStorePath("openclaw-usage-");
3086+
const sessionKey = "main";
3087+
await seedSessionStore({
3088+
storePath,
3089+
sessionKey,
3090+
entry: {
3091+
sessionId: "s1",
3092+
updatedAt: Date.now(),
3093+
totalTokens: 42_000,
3094+
totalTokensFresh: true,
3095+
},
3096+
});
3097+
3098+
await persistSessionUsageUpdate({
3099+
storePath,
3100+
sessionKey,
3101+
usage: { input: 50_000, output: 5_000, total: 55_000 },
3102+
contextTokensUsed: 200_000,
3103+
});
3104+
3105+
const stored = JSON.parse(await fs.readFile(storePath, "utf-8"));
3106+
expect(stored[sessionKey].totalTokens).toBe(42_000);
3107+
expect(stored[sessionKey].totalTokensFresh).toBe(false);
3108+
});
3109+
30573110
it("uses promptTokens when available without lastCallUsage", async () => {
30583111
const storePath = await createStorePath("openclaw-usage-");
30593112
const sessionKey = "main";

0 commit comments

Comments
 (0)