Skip to content

Commit b8e9ab9

Browse files
authored
fix(codex): surface native compaction failures (#85160)
* fix(codex): surface native compaction failures * docs: add changelog for codex compaction fix * test: align compaction failure fixtures
1 parent c8a35c4 commit b8e9ab9

44 files changed

Lines changed: 3170 additions & 325 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ Docs: https://docs.openclaw.ai
4242
- Agents: cap heartbeat model bleed context hints by the stored session window when runtime model metadata is unavailable, so overflow recovery advice does not suggest a larger window than the active session actually has.
4343
- Control UI/Web Push: use `https://openclaw.ai` as the generated default VAPID subject instead of the old localhost mailbox so iOS PWA push setup uses an Apple-acceptable subject when `OPENCLAW_VAPID_SUBJECT` is unset. Fixes #83134. (#83317) Thanks @IWhatsskill.
4444
- Agents/Pi: keep embedded session transcript writes from tripping false takeover detection after packaged npm onboarding agent turns.
45+
- Codex/TUI: surface Codex-native post-turn compaction failures instead of continuing uncompacted, and keep successful native compaction serialized before local idle/next-turn handling. Fixes #84305. (#85160) Thanks @joshavant.
4546
- Memory/search: stop recall tracking from writing dreaming side-effect artifacts when `dreaming.enabled=false`, while preserving normal search results. Fixes #84436. (#84444) Thanks @NianJiuZst.
4647
- Diffs: render viewer toolbar icons from a closed icon-name map instead of HTML strings, removing the toolbar icon XSS sink. (#83955) Thanks @tanshanshan.
4748
- QA: keep `pnpm qa:e2e` self-check runs inside the private QA runtime envelope even when inherited shell env disables bundled plugins.

extensions/codex/src/app-server/compact.test.ts

Lines changed: 198 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -110,16 +110,29 @@ describe("maybeCompactCodexAppServerSession", () => {
110110
method: "thread/compacted",
111111
params: { threadId: "thread-1", turnId: "turn-1" },
112112
});
113+
fake.emit({
114+
method: "thread/tokenUsage/updated",
115+
params: {
116+
threadId: "thread-1",
117+
tokenUsage: {
118+
last_token_usage: {
119+
total_tokens: 27_170,
120+
},
121+
},
122+
},
123+
});
113124
const result = requireCompactResult(await pendingResult);
114125

115126
expect(result.ok).toBe(true);
116127
expect(result.compacted).toBe(true);
117128
expect(result.result?.tokensBefore).toBe(123);
129+
expect(result.result?.tokensAfter).toBe(27_170);
118130
const details = compactDetails(result);
119131
expect(details.backend).toBe("codex-app-server");
120132
expect(details.threadId).toBe("thread-1");
121133
expect(details.signal).toBe("thread/compacted");
122134
expect(details.turnId).toBe("turn-1");
135+
expect(details.tokenUsageSource).toBe("thread/tokenUsage/updated");
123136
});
124137

125138
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
@@ -137,7 +150,73 @@ describe("maybeCompactCodexAppServerSession", () => {
137150
expect(fake.request).not.toHaveBeenCalled();
138151
});
139152

140-
it("accepts native context-compaction item completion as success", async () => {
153+
it("uses native token usage that arrives before compaction completion", async () => {
154+
const fake = createFakeCodexClient();
155+
setCodexAppServerClientFactoryForTest(async () => fake.client);
156+
const sessionFile = await writeTestBinding();
157+
158+
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
159+
await vi.waitFor(() => {
160+
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
161+
});
162+
163+
fake.emit({
164+
method: "thread/tokenUsage/updated",
165+
params: {
166+
threadId: "thread-1",
167+
tokenUsage: {
168+
last_token_usage: {
169+
total_tokens: 18_004,
170+
},
171+
},
172+
},
173+
});
174+
fake.emit({
175+
method: "thread/compacted",
176+
params: { threadId: "thread-1", turnId: "turn-1" },
177+
});
178+
const result = requireCompactResult(await pendingResult);
179+
180+
expect(result.ok).toBe(true);
181+
expect(result.compacted).toBe(true);
182+
expect(result.result?.tokensAfter).toBe(18_004);
183+
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
184+
});
185+
186+
it("accepts native current token usage with a total alias", async () => {
187+
const fake = createFakeCodexClient();
188+
setCodexAppServerClientFactoryForTest(async () => fake.client);
189+
const sessionFile = await writeTestBinding();
190+
191+
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
192+
await vi.waitFor(() => {
193+
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
194+
});
195+
196+
fake.emit({
197+
method: "thread/tokenUsage/updated",
198+
params: {
199+
threadId: "thread-1",
200+
tokenUsage: {
201+
last: {
202+
total: 16_384,
203+
},
204+
},
205+
},
206+
});
207+
fake.emit({
208+
method: "thread/compacted",
209+
params: { threadId: "thread-1", turnId: "turn-1" },
210+
});
211+
const result = requireCompactResult(await pendingResult);
212+
213+
expect(result.ok).toBe(true);
214+
expect(result.compacted).toBe(true);
215+
expect(result.result?.tokensAfter).toBe(16_384);
216+
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
217+
});
218+
219+
it("accepts native context-compaction item completion with unknown token count as success", async () => {
141220
const fake = createFakeCodexClient();
142221
setCodexAppServerClientFactoryForTest(async () => fake.client);
143222
const sessionFile = await writeTestBinding();
@@ -158,11 +237,44 @@ describe("maybeCompactCodexAppServerSession", () => {
158237
const result = requireCompactResult(await pendingResult);
159238
expect(result.ok).toBe(true);
160239
expect(result.compacted).toBe(true);
240+
expect(result.result?.tokensAfter).toBeUndefined();
161241
const details = compactDetails(result);
162242
expect(details.signal).toBe("item/completed");
163243
expect(details.itemId).toBe("compact-1");
164244
});
165245

246+
it("does not treat zero native token usage as an authoritative post-compaction count", async () => {
247+
const fake = createFakeCodexClient();
248+
setCodexAppServerClientFactoryForTest(async () => fake.client);
249+
const sessionFile = await writeTestBinding();
250+
251+
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
252+
await vi.waitFor(() => {
253+
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
254+
});
255+
fake.emit({
256+
method: "thread/compacted",
257+
params: { threadId: "thread-1", turnId: "turn-1" },
258+
});
259+
fake.emit({
260+
method: "thread/tokenUsage/updated",
261+
params: {
262+
threadId: "thread-1",
263+
tokenUsage: {
264+
last_token_usage: {
265+
total_tokens: 0,
266+
},
267+
},
268+
},
269+
});
270+
271+
const result = requireCompactResult(await pendingResult);
272+
expect(result.ok).toBe(true);
273+
expect(result.compacted).toBe(true);
274+
expect(result.result?.tokensAfter).toBeUndefined();
275+
expect(compactDetails(result).tokenUsageSource).toBeUndefined();
276+
});
277+
166278
it("reuses the bound auth profile for native compaction", async () => {
167279
const fake = createFakeCodexClient();
168280
let seenAuthProfileId: string | undefined;
@@ -185,6 +297,39 @@ describe("maybeCompactCodexAppServerSession", () => {
185297
expect(seenAuthProfileId).toBe("openai-codex:work");
186298
});
187299

300+
it("reports missing thread bindings as failed native compaction", async () => {
301+
const sessionFile = path.join(tempDir, "missing-binding.jsonl");
302+
303+
const result = requireCompactResult(
304+
await startCompaction(sessionFile, { currentTokenCount: 123 }),
305+
);
306+
307+
expect(result.ok).toBe(false);
308+
expect(result.compacted).toBe(false);
309+
expect(result.reason).toBe("no codex app-server thread binding");
310+
expect(result.failure?.reason).toBe("missing_thread_binding");
311+
expect(result.result).toBeUndefined();
312+
});
313+
314+
it("clears stale thread bindings and reports failed native compaction", async () => {
315+
const fake = createFakeCodexClient();
316+
fake.request.mockRejectedValueOnce(new Error("thread not found: thread-1"));
317+
setCodexAppServerClientFactoryForTest(async () => fake.client);
318+
const sessionFile = await writeTestBinding();
319+
320+
const result = requireCompactResult(
321+
await startCompaction(sessionFile, { currentTokenCount: 456 }),
322+
);
323+
324+
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
325+
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
326+
expect(result.ok).toBe(false);
327+
expect(result.compacted).toBe(false);
328+
expect(result.reason).toBe("thread not found: thread-1");
329+
expect(result.failure?.reason).toBe("stale_thread_binding");
330+
expect(result.result).toBeUndefined();
331+
});
332+
188333
it("warns when stale OpenClaw compaction overrides are ignored", async () => {
189334
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
190335
const fake = createFakeCodexClient();
@@ -541,6 +686,58 @@ describe("maybeCompactCodexAppServerSession", () => {
541686
);
542687
});
543688

689+
it("honors explicit force for budget-triggered owning context-engine compaction", async () => {
690+
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
691+
const sessionFile = await writeTestBinding();
692+
const compact = vi.fn(async () => ({
693+
ok: true,
694+
compacted: true,
695+
result: {
696+
summary: "engine summary",
697+
firstKeptEntryId: "entry-1",
698+
tokensBefore: 900,
699+
tokensAfter: 100,
700+
},
701+
}));
702+
const contextEngine: ContextEngine = {
703+
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
704+
assemble: vi.fn() as never,
705+
ingest: vi.fn() as never,
706+
compact,
707+
};
708+
709+
const result = requireCompactResult(
710+
await maybeCompactCodexAppServerSession({
711+
sessionId: "session-1",
712+
sessionKey: "agent:main:session-1",
713+
sessionFile,
714+
workspaceDir: tempDir,
715+
contextEngine,
716+
contextTokenBudget: 777,
717+
currentTokenCount: 900,
718+
trigger: "budget",
719+
force: true,
720+
}),
721+
);
722+
723+
expect(result.ok).toBe(true);
724+
expect(result.compacted).toBe(true);
725+
expect(compact).toHaveBeenCalledWith(
726+
expect.objectContaining({
727+
compactionTarget: "budget",
728+
force: true,
729+
}),
730+
);
731+
expect(info).toHaveBeenCalledWith(
732+
"starting context-engine-owned Codex app-server compaction",
733+
expect.objectContaining({
734+
trigger: "budget",
735+
compactionTarget: "budget",
736+
force: true,
737+
}),
738+
);
739+
});
740+
544741
it("adopts successor transcript handles after owning context-engine compaction", async () => {
545742
const sessionFile = await writeTestBinding();
546743
const successorFile = path.join(tempDir, "session.compacted.jsonl");

0 commit comments

Comments
 (0)