Skip to content

Commit 6a65ea8

Browse files
authored
fix: preserve post-compaction session token freshness (#82578)
Fixes #82576. Keeps post-compaction token totals fresh across stale usage updates and adds regression coverage for the repeated auto-compaction loop. Also includes maintainer fixups needed to keep the touched CI lanes green: guarded GitHub Copilot device-flow fetches, dead-session metadata recreation, and current cron stale-data expectations. Co-authored-by: njuboy11 <[email protected]>
1 parent b9921e2 commit 6a65ea8

12 files changed

Lines changed: 168 additions & 43 deletions

CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ 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.
21+
- Gateway/sessions: discard stale metadata when recreating dead main session rows, so replacement sessions do not inherit old labels or transcript paths.
2022
- 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.
2123
- WebChat: show progress while manual `/compact` is running by streaming a session operation event to subscribed Control UI clients. Fixes #82407. Thanks @Conan-Scott.
2224
- 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.

extensions/github-copilot/index.test.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import type {
1414
} from "openclaw/plugin-sdk/plugin-entry";
1515
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
1616
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
17+
import { _setGitHubCopilotDeviceFlowFetchGuardForTesting } from "./login.js";
1718

1819
const mocks = vi.hoisted(() => ({
1920
githubCopilotLoginCommand: vi.fn(),
@@ -49,6 +50,7 @@ type GithubCopilotTestModelCatalogProvider = {
4950
afterEach(async () => {
5051
vi.clearAllMocks();
5152
vi.unstubAllGlobals();
53+
_setGitHubCopilotDeviceFlowFetchGuardForTesting(null);
5254
clearRuntimeAuthProfileStoreSnapshots();
5355
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
5456
});
@@ -313,7 +315,7 @@ describe("github-copilot plugin", () => {
313315
},
314316
}),
315317
);
316-
const fetchMock = vi.fn(async (input: unknown) => {
318+
const fetchMock = vi.fn(async (input: unknown, _init?: RequestInit) => {
317319
const target =
318320
typeof input === "string"
319321
? input
@@ -343,6 +345,11 @@ describe("github-copilot plugin", () => {
343345
throw new Error(`unexpected fetch in github-copilot refresh test: ${target}`);
344346
});
345347
vi.stubGlobal("fetch", fetchMock);
348+
_setGitHubCopilotDeviceFlowFetchGuardForTesting(async (params) => ({
349+
response: await fetchMock(params.url, params.init),
350+
finalUrl: params.url,
351+
release: async () => {},
352+
}));
346353
const prompter = {
347354
confirm: vi.fn(async () => true),
348355
note: vi.fn(),

extensions/github-copilot/login.ts

Lines changed: 45 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
upsertAuthProfileWithLock,
88
} from "openclaw/plugin-sdk/provider-auth";
99
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
10+
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
1011

1112
const CLIENT_ID = "Iv1.b507a08c87ecfe98";
1213
const DEVICE_CODE_URL = "https://github.com/login/device/code";
@@ -47,6 +48,14 @@ class GitHubDeviceFlowError extends Error {
4748
}
4849
}
4950

51+
let githubDeviceFlowFetchGuard = fetchWithSsrFGuard;
52+
53+
export function _setGitHubCopilotDeviceFlowFetchGuardForTesting(
54+
impl: typeof fetchWithSsrFGuard | null,
55+
): void {
56+
githubDeviceFlowFetchGuard = impl ?? fetchWithSsrFGuard;
57+
}
58+
5059
async function upsertAuthProfileWithLockOrThrow(params: UpsertAuthProfileParams): Promise<void> {
5160
const updated = await upsertAuthProfileWithLock(params);
5261
if (!updated) {
@@ -71,26 +80,45 @@ function parseJsonResponse(value: unknown): Record<string, unknown> {
7180
return value as Record<string, unknown>;
7281
}
7382

83+
async function postGitHubDeviceFlowForm(params: {
84+
url: string;
85+
body: URLSearchParams;
86+
failureLabel: string;
87+
}): Promise<Record<string, unknown>> {
88+
const { response, release } = await githubDeviceFlowFetchGuard({
89+
url: params.url,
90+
init: {
91+
method: "POST",
92+
headers: {
93+
Accept: "application/json",
94+
"Content-Type": "application/x-www-form-urlencoded",
95+
},
96+
body: params.body,
97+
},
98+
requireHttps: true,
99+
auditContext: "github-copilot-device-flow",
100+
});
101+
try {
102+
if (!response.ok) {
103+
throw new Error(`${params.failureLabel}: HTTP ${response.status}`);
104+
}
105+
return parseJsonResponse(await response.json());
106+
} finally {
107+
await release();
108+
}
109+
}
110+
74111
async function requestDeviceCode(params: { scope: string }): Promise<DeviceCodeResponse> {
75112
const body = new URLSearchParams({
76113
client_id: CLIENT_ID,
77114
scope: params.scope,
78115
});
79116

80-
const res = await fetch(DEVICE_CODE_URL, {
81-
method: "POST",
82-
headers: {
83-
Accept: "application/json",
84-
"Content-Type": "application/x-www-form-urlencoded",
85-
},
117+
const json = (await postGitHubDeviceFlowForm({
118+
url: DEVICE_CODE_URL,
86119
body,
87-
});
88-
89-
if (!res.ok) {
90-
throw new Error(`GitHub device code failed: HTTP ${res.status}`);
91-
}
92-
93-
const json = parseJsonResponse(await res.json()) as DeviceCodeResponse;
120+
failureLabel: "GitHub device code failed",
121+
})) as DeviceCodeResponse;
94122
if (!json.device_code || !json.user_code || !json.verification_uri) {
95123
throw new Error("GitHub device code response missing fields");
96124
}
@@ -109,20 +137,11 @@ async function pollForAccessToken(params: {
109137
});
110138

111139
while (Date.now() < params.expiresAt) {
112-
const res = await fetch(ACCESS_TOKEN_URL, {
113-
method: "POST",
114-
headers: {
115-
Accept: "application/json",
116-
"Content-Type": "application/x-www-form-urlencoded",
117-
},
140+
const json = (await postGitHubDeviceFlowForm({
141+
url: ACCESS_TOKEN_URL,
118142
body: bodyBase,
119-
});
120-
121-
if (!res.ok) {
122-
throw new Error(`GitHub device token failed: HTTP ${res.status}`);
123-
}
124-
125-
const json = parseJsonResponse(await res.json()) as DeviceTokenResponse;
143+
failureLabel: "GitHub device token failed",
144+
})) as DeviceTokenResponse;
126145
if ("access_token" in json && typeof json.access_token === "string") {
127146
return json.access_token;
128147
}

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: 10 additions & 4 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,10 +155,15 @@ export async function persistSessionUsageUpdate(params: {
154155
if (runEstimatedCostUsd !== undefined) {
155156
patch.estimatedCostUsd = runEstimatedCostUsd;
156157
}
157-
// Missing a last-call snapshot (and promptTokens fallback) means
158-
// context utilization is stale/unknown.
159-
patch.totalTokens = totalTokens;
160-
patch.totalTokensFresh = typeof totalTokens === "number";
158+
if (hasFreshContextSnapshot) {
159+
patch.totalTokens = totalTokens;
160+
patch.totalTokensFresh = true;
161+
} else if (
162+
params.preserveFreshTotalTokensOnStaleUsage !== true ||
163+
entry.totalTokensFresh !== true
164+
) {
165+
patch.totalTokensFresh = false;
166+
}
161167
return applyCliSessionIdToSessionPatch(params, entry, patch);
162168
},
163169
});

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

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

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

src/cron/service.skips-main-jobs-empty-systemevent-text.test.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ describe("CronService", () => {
9494
expect(requestHeartbeat).not.toHaveBeenCalled();
9595
});
9696

97-
it("drops persisted main jobs with empty systemEvent text before they run", async () => {
97+
it("disables persisted main jobs with empty systemEvent text after skipping them", async () => {
9898
await withCronService(true, async ({ cron, enqueueSystemEvent, requestHeartbeat }) => {
9999
const atMs = Date.parse("2025-12-13T00:00:01.000Z");
100100
await cron.add({
@@ -112,8 +112,9 @@ describe("CronService", () => {
112112
expect(enqueueSystemEvent).not.toHaveBeenCalled();
113113
expect(requestHeartbeat).not.toHaveBeenCalled();
114114

115-
const job = await waitForFirstJob(cron, (current) => current === undefined);
116-
expect(job).toBeUndefined();
115+
const job = await waitForFirstJob(cron, (current) => current?.state.lastStatus === "skipped");
116+
expect(job?.enabled).toBe(false);
117+
expect(job?.state.lastError).toMatch(/non-empty/i);
117118
});
118119
});
119120

src/cron/service/store.test.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -291,7 +291,7 @@ describe("cron service store seam coverage", () => {
291291
expect(findJobOrThrow(state, "reload-cron-expr-job").state.nextRunAtMs).toBe(dueNextRunAtMs);
292292
});
293293

294-
it("skips a force-reloaded job when the persisted schedule is malformed", async () => {
294+
it("keeps a force-reloaded legacy string schedule for runtime repair handling", async () => {
295295
const { storePath } = await makeStorePath();
296296
const staleNextRunAtMs = STORE_TEST_NOW + 3_600_000;
297297

@@ -316,12 +316,9 @@ describe("cron service store seam coverage", () => {
316316
undefined,
317317
);
318318

319-
expect(state.store?.jobs.find((job) => job.id === "reload-cron-expr-job")).toBeUndefined();
320-
expectWarnedJob({
321-
storePath,
322-
jobId: "reload-cron-expr-job",
323-
message: "skipped invalid persisted job",
324-
});
319+
const job = findJobOrThrow(state, "reload-cron-expr-job");
320+
expect(job.schedule).toBe("0 17 * * *");
321+
expect(job.state.nextRunAtMs).toBeUndefined();
325322
});
326323

327324
it("preserves nextRunAtMs after force reload when scheduling inputs are unchanged", async () => {

0 commit comments

Comments
 (0)