Fail Codex compaction at the Codex boundary (#85958)

This commit is contained in:
pashpashpash
2026-05-24 22:12:34 -07:00
committed by GitHub
parent 908b894432
commit dd47e479ae
18 changed files with 716 additions and 1904 deletions
+2 -2
View File
@@ -247,7 +247,7 @@ Docs: https://docs.openclaw.ai
- Gateway/agents: preserve fresh session overrides and metadata when stale cached agent-session entries race with store updates, so subagent model/provider overrides and routing policy survive concurrent writes. (#19328) Thanks @CodeReclaimers.
- Control UI/chat: keep chat session search inline with the session selector so the header no longer shows a duplicate standalone search row.
- Control UI/chat: collapse focused-mode header chrome and suppress hidden-header scroll updates so focus mode no longer jumps while scrolling. Thanks @amknight.
- Codex app-server: restart the native app-server and retry once when server-side compaction times out, so preflight compaction stalls recover instead of failing every dispatch. (#85500)
- Codex app-server: leave automatic compaction to native Codex, drop OpenClaw preflight/CLI/context-engine forced compaction for Codex runtime sessions, and still forward explicit `/compact` or plugin compaction requests into Codex while failing native compaction honestly. (#85500)
- Restore Control UI gateway token pairing [AI]. (#85459) Thanks @pgondhi987.
- OpenAI video: honor configured provider request private-network opt-in for local/custom video endpoints so explicitly trusted mock and self-hosted providers are not blocked. Thanks @shakkernerd.
- OpenAI video: send uploaded video edit requests to the documented `/videos/edits` endpoint with a `video` file instead of posting MP4 references to `/videos`. Thanks @shakkernerd.
@@ -332,7 +332,7 @@ Docs: https://docs.openclaw.ai
- fix: constrain Windows task script names [AI]. (#85064) Thanks @pgondhi987.
- Control UI: keep the chat session picker from hiding older or cross-agent configured conversations while preserving the bounded configured-agent refresh. (#85211) Thanks @amknight.
- Agents/Anthropic: preserve unsafe integer tool-call input values in streamed Anthropic tool-use JSON, preventing Discord-style IDs from being rounded before dispatch. Fixes #47229. (#83063) Thanks @leno23.
- Agents/Codex: estimate tool-heavy prompt pressure at the LLM boundary before provider submission, so persistent sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents: estimate tool-heavy prompt pressure at the LLM boundary before provider submission for non-Codex embedded runtimes, so persistent PI-style sessions compact before overflowing context windows. (#85541) Thanks @fuller-stack-dev and @joshavant.
- Agents/hooks: wait for local one-shot CLI and Codex `agent_end` plugin hooks before process cleanup so terminal observability flushes reliably. (#85007)
- Providers/Google: preserve Gemini 3 cron `thinkingDefault: "low"` when stale catalog metadata says `reasoning:false`, so scheduled runs keep provider-supported thinking instead of downgrading to off. (#85185) Thanks @neeravmakwana.
- CLI/agents: allow `openclaw agent --session-key` to target explicit session keys, including agent-scoped legacy keys. (#85121) Thanks @Kaspre.
+19 -15
View File
@@ -20,8 +20,7 @@ diagnostic surfaces around that boundary.
OpenClaw still owns channel routing, session files, visible message delivery,
OpenClaw dynamic tools, approvals, media delivery, and a transcript mirror.
Codex owns the canonical native thread, native model loop, native tool
continuation, and native compaction unless the active OpenClaw context engine
declares that it owns compaction.
continuation, and native compaction.
Prompt routing follows the selected runtime, not just the provider string. A
native Codex turn receives Codex app-server developer instructions, while an
@@ -122,7 +121,7 @@ Supported in Codex runtime v1:
| OpenClaw channel routing and delivery | Supported | Telegram, Discord, Slack, WhatsApp, iMessage, and other channels stay outside the model runtime. |
| OpenClaw dynamic tools | Supported | Codex asks OpenClaw to execute these tools, so OpenClaw stays in the execution path. |
| Prompt and context plugins | Supported | OpenClaw projects OpenClaw-specific prompt/context into the Codex turn while leaving Codex-owned base, model, personality, and configured project-doc prompts in the native Codex lane. Native Codex developer instructions accept only command guidance explicitly scoped to `codex_app_server`; legacy global command hints remain for non-Codex prompt surfaces. |
| Context engine lifecycle | Supported | Assemble, ingest, after-turn maintenance, and context-engine compaction coordination run for Codex turns. |
| Context engine lifecycle | Supported | Assemble, ingest, and after-turn maintenance run around Codex turns. Context engines do not replace native Codex compaction. |
| Dynamic tool hooks | Supported | `before_tool_call`, `after_tool_call`, and tool-result middleware run around OpenClaw-owned dynamic tools. |
| Lifecycle hooks | Supported as adapter observations | `llm_input`, `llm_output`, `agent_end`, `before_compaction`, and `after_compaction` fire with honest Codex-mode payloads. |
| Final-answer revision gate | Supported through native hook relay | Codex `Stop` is relayed to `before_agent_finalize`; `revise` asks Codex for one more model pass before finalization. |
@@ -137,8 +136,8 @@ Not supported in Codex runtime v1:
| Native tool argument mutation | Codex native pre-tool hooks can block, but OpenClaw does not rewrite Codex-native tool arguments. | Requires Codex hook/schema support for replacement tool input. |
| Editable Codex-native transcript history | Codex owns canonical native thread history. OpenClaw owns a mirror and can project future context, but should not mutate unsupported internals. | Add explicit Codex app-server APIs if native thread surgery is needed. |
| `tool_result_persist` for Codex-native tool records | That hook transforms OpenClaw-owned transcript writes, not Codex-native tool records. | Could mirror transformed records, but canonical rewrite needs Codex support. |
| Rich native compaction metadata | OpenClaw observes compaction start and completion, but does not receive a stable kept/dropped list, token delta, or summary payload. | Needs richer Codex compaction events. |
| Compaction intervention | Current OpenClaw compaction hooks are notification-level in Codex mode. | Add Codex pre/post compaction hooks if plugins need to veto or rewrite native compaction. |
| Rich native compaction metadata | OpenClaw can request native compaction, but does not receive a stable kept/dropped list, token delta, completion summary, or summary payload. | Needs richer Codex compaction events. |
| Compaction intervention | OpenClaw does not let plugins or context engines veto, rewrite, or replace native Codex compaction. | Add Codex pre/post compaction hooks if plugins need to veto or rewrite native compaction. |
| Byte-for-byte model API request capture | OpenClaw can capture app-server requests and notifications, but Codex core builds the final OpenAI API request internally. | Needs a Codex model-request tracing event or debug API. |
## Native permissions and MCP elicitations
@@ -200,13 +199,18 @@ diagnostics bundle.
## Compaction and transcript mirror
When the selected model uses the Codex harness, native thread compaction is
delegated to Codex app-server unless an active context engine declares
`ownsCompaction: true`. Owning context engines compact first and cause OpenClaw
to abandon the old Codex backend thread so the next turn can rehydrate a fresh
thread from engine-managed context. OpenClaw keeps a transcript mirror for
channel history, search, `/new`, `/reset`, and future model or harness
switching.
When the selected model uses the Codex harness, native thread compaction belongs
to Codex app-server. OpenClaw does not run preflight compaction for Codex turns,
does not replace Codex compaction with context-engine compaction, and does not
fall back to OpenClaw or public OpenAI summarization when native Codex
compaction cannot be started. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching.
Explicit compaction requests, such as `/compact` or a plugin-requested manual
compact operation, start native Codex compaction with `thread/compact/start`.
OpenClaw returns after starting that native operation. It does not wait for
completion, impose a separate OpenClaw timeout, restart the shared Codex
app-server, or record the operation as an OpenClaw-completed compaction.
When a context engine requests Codex thread-bootstrap projection, OpenClaw
projects tool-call names and ids, input shapes, and redacted tool-result content
@@ -215,9 +219,9 @@ that projection.
The mirror includes the user prompt, final assistant text, and lightweight Codex
reasoning or plan records when the app-server emits them. Today, OpenClaw only
records native compaction start and completion signals. It does not yet expose a
human-readable compaction summary or an auditable list of which entries Codex
kept after compaction.
records explicit native compaction start signals when it requests compaction. It
does not expose a human-readable compaction summary or an auditable list of
which entries Codex kept after compaction.
Because Codex owns the canonical native thread, `tool_result_persist` does not
currently rewrite Codex-native tool result records. It only applies when
+17 -16
View File
@@ -125,28 +125,29 @@ Use `openai/gpt-*` model refs for Codex-backed OpenAI agent turns. Prefer
`openai-codex:*` auth profiles and `auth.order.openai-codex` remain valid, but
do not write new `openai-codex/gpt-*` model refs.
Do not set `compaction.model` or `compaction.provider` on Codex-backed agents
unless a selected context engine owns compaction. Without an owning context
engine, Codex compacts through its native app-server thread state, so OpenClaw
ignores those local summarizer overrides at runtime and `openclaw doctor --fix`
removes them when the agent uses Codex.
Do not set `compaction.model` or `compaction.provider` on Codex-backed agents.
Codex compacts through its native app-server thread state, so OpenClaw ignores
those local summarizer overrides at runtime and `openclaw doctor --fix` removes
them when the agent uses Codex.
Lossless remains supported as a context engine. Configure it through
Lossless remains supported as a context engine for assembly, ingestion, and
maintenance around Codex turns. Configure it through
`plugins.slots.contextEngine: "lossless-claw"` and
`plugins.entries.lossless-claw.config.summaryModel`, not through
`agents.defaults.compaction.provider`. `openclaw doctor --fix` migrates the old
`compaction.provider: "lossless-claw"` shape to the Lossless context-engine slot
when Codex is the active runtime.
when Codex is the active runtime, but native Codex still owns compaction.
The native Codex app-server harness supports context engines that require
pre-prompt assembly. Generic CLI backends, including `codex-cli`, do not provide
that host capability.
When the active context engine reports `ownsCompaction: true`, `/compact` runs
that engine's compaction lifecycle and invalidates the bound Codex app-server
thread. The next Codex turn starts a fresh backend thread and rehydrates it from
the context engine instead of layering Codex native compaction on top of the
engine-owned semantic summary.
For Codex-backed agents, `/compact` starts native Codex app-server compaction on
the bound thread. OpenClaw does not wait for completion, impose an OpenClaw
timeout, restart the shared app-server, or fall back to a context-engine or
public OpenAI summarizer. If the native Codex thread binding is missing or
stale, the command fails closed so the operator sees the real runtime boundary
instead of silently switching compaction backends.
```json5
{
@@ -656,10 +657,10 @@ The Codex harness changes the low-level embedded agent executor only.
- Codex-native shell, patch, MCP, and native app tools are owned by Codex.
OpenClaw can observe or block selected native events through the supported
relay, but it does not rewrite native tool arguments.
- Codex owns native compaction unless the active OpenClaw context engine
declares `ownsCompaction: true`. OpenClaw keeps a transcript mirror for
channel history, search, `/new`, `/reset`, and future model or harness
switching.
- Codex owns native compaction. OpenClaw keeps a transcript mirror for channel
history, search, `/new`, `/reset`, and future model or harness switching, but
it does not replace Codex compaction with an OpenClaw or context-engine
summarizer.
- Media generation, media understanding, TTS, approvals, and messaging-tool
output continue through the matching OpenClaw provider/model settings.
- `tool_result_persist` applies to OpenClaw-owned transcript tool results, not
+151 -660
View File
@@ -99,50 +99,26 @@ describe("maybeCompactCodexAppServerSession", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
it("waits for native app-server compaction before reporting success", async () => {
it("starts native app-server compaction without waiting for completion", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
let settled = false;
void pendingResult.then(() => {
settled = true;
});
await Promise.resolve();
expect(settled).toBe(false);
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 27_170,
},
},
},
});
const result = requireCompactResult(await pendingResult);
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 123 }),
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(fake.client.addNotificationHandler).not.toHaveBeenCalled();
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.compacted).toBe(false);
expect(result.result?.tokensBefore).toBe(123);
expect(result.result?.tokensAfter).toBe(27_170);
expect(result.result?.tokensAfter).toBeUndefined();
const details = compactDetails(result);
expect(details.backend).toBe("codex-app-server");
expect(details.threadId).toBe("thread-1");
expect(details.signal).toBe("thread/compacted");
expect(details.turnId).toBe("turn-1");
expect(details.tokenUsageSource).toBe("thread/tokenUsage/updated");
expect(details.signal).toBe("thread/compact/start");
expect(details.pending).toBe(true);
});
it("blocks native app-server compaction when the current OpenClaw session is sandboxed", async () => {
@@ -175,108 +151,14 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(fake.request).not.toHaveBeenCalled();
});
it("uses native token usage that arrives before compaction completion", async () => {
it("does not consume native completion notifications after forwarding the request", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 18_004,
},
},
},
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBe(18_004);
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
});
it("accepts native current token usage with a total alias", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last: {
total: 16_384,
},
},
},
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBe(16_384);
expect(compactDetails(result).tokenUsageSource).toBe("thread/tokenUsage/updated");
});
it("accepts native context-compaction item completion with unknown token count as success", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: { type: "contextCompaction", id: "compact-1" },
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBeUndefined();
const details = compactDetails(result);
expect(details.signal).toBe("item/completed");
expect(details.itemId).toBe("compact-1");
});
it("does not treat zero native token usage as an authoritative post-compaction count", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 123 });
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 123 }),
);
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
@@ -293,11 +175,11 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.compacted).toBe(false);
expect(result.result?.tokensAfter).toBeUndefined();
expect(compactDetails(result).tokenUsageSource).toBeUndefined();
expect(compactDetails(result).signal).toBe("thread/compact/start");
});
it("reuses the bound auth profile for native compaction", async () => {
@@ -309,17 +191,10 @@ describe("maybeCompactCodexAppServerSession", () => {
});
const sessionFile = await writeTestBinding({ authProfileId: "openai-codex:work" });
const pendingResult = startCompaction(sessionFile);
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
const result = requireCompactResult(await startCompaction(sessionFile));
expect(seenAuthProfileId).toBe("openai-codex:work");
expect(result.ok).toBe(true);
});
it("reports missing thread bindings as failed native compaction", async () => {
@@ -355,87 +230,27 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(result.result).toBeUndefined();
});
it("restarts the Codex app-server and retries when native compaction times out", async () => {
const previousTimeout = process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS;
process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS = "100";
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
try {
const first = createFakeCodexClient();
const second = createFakeCodexClient();
let factoryCalls = 0;
const factory = vi.fn(async () => {
factoryCalls += 1;
if (factoryCalls === 1) {
return first.client;
}
return second.client;
});
setCodexAppServerClientFactoryForTest(factory);
const sessionFile = await writeTestBinding();
it("does not impose an OpenClaw timeout after Codex accepts native compaction", async () => {
const fake = createFakeCodexClient();
const factory = vi.fn(async () => fake.client);
setCodexAppServerClientFactoryForTest(factory);
const sessionFile = await writeTestBinding();
const pendingResult = startCompaction(sessionFile, { currentTokenCount: 456 });
await vi.waitFor(() => {
expect(first.request).toHaveBeenCalledWith("thread/compact/start", {
threadId: "thread-1",
});
});
const result = requireCompactResult(
await startCompaction(sessionFile, { currentTokenCount: 456 }),
);
await vi.waitFor(() => {
expect(first.close).toHaveBeenCalledTimes(1);
expect(second.request).toHaveBeenCalledWith("thread/compact/start", {
threadId: "thread-1",
});
});
second.emit({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-1",
tokenUsage: {
last_token_usage: {
total_tokens: 12_345,
},
},
},
});
second.emit({
method: "item/completed",
params: {
threadId: "thread-1",
turnId: "turn-2",
item: { type: "contextCompaction", id: "compact-2" },
},
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.tokensAfter).toBe(12_345);
expect(factory).toHaveBeenCalledTimes(2);
expect(second.close).not.toHaveBeenCalled();
expect(await readCodexAppServerBinding(sessionFile)).toBeDefined();
const details = compactDetails(result);
expect(details.signal).toBe("item/completed");
expect(details.itemId).toBe("compact-2");
expect(details.compactionAttempts).toBe(2);
expect(details.recoveredAfterAppServerRestart).toBe(true);
expect(warn).toHaveBeenCalledWith(
"codex app-server compaction timed out; restarting app-server",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
threadId: "thread-1",
attempt: 1,
maxAttempts: 2,
}),
);
} finally {
if (previousTimeout === undefined) {
delete process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS;
} else {
process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS = previousTimeout;
}
warn.mockRestore();
}
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
});
expect(factory).toHaveBeenCalledTimes(1);
expect(fake.close).not.toHaveBeenCalled();
expect(await readCodexAppServerBinding(sessionFile)).toBeDefined();
});
it("warns when stale OpenClaw compaction overrides are ignored", async () => {
@@ -444,7 +259,7 @@ describe("maybeCompactCodexAppServerSession", () => {
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSession({
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
@@ -460,15 +275,8 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
@@ -486,16 +294,16 @@ describe("maybeCompactCodexAppServerSession", () => {
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSession({
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
sessionKey: "agent:sara:session-1",
sessionFile,
workspaceDir: tempDir,
config: {
agents: {
list: [
{
id: "nik",
id: "sara",
compaction: {
model: "openai/gpt-5.4-mini",
provider: "openai",
@@ -505,21 +313,17 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
ignoredConfig: ["agents.list.nik.compaction.model", "agents.list.nik.compaction.provider"],
sessionKey: "agent:sara:session-1",
ignoredConfig: [
"agents.list.sara.compaction.model",
"agents.list.sara.compaction.provider",
],
},
);
warn.mockRestore();
@@ -531,7 +335,7 @@ describe("maybeCompactCodexAppServerSession", () => {
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const pendingResult = maybeCompactCodexAppServerSession({
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
sessionFile,
@@ -554,15 +358,8 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
});
await vi.waitFor(() => {
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
});
fake.emit({
method: "thread/compacted",
params: { threadId: "thread-1", turnId: "turn-1" },
});
await pendingResult;
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
@@ -574,8 +371,10 @@ describe("maybeCompactCodexAppServerSession", () => {
warn.mockRestore();
});
it("does not warn for legacy Lossless config when the Lossless context engine slot is active", async () => {
it("warns for legacy Lossless config even when the Lossless context engine slot is active", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const contextEngine: ContextEngine = {
info: { id: "lcm", name: "Lossless Context Manager", ownsCompaction: true },
@@ -586,7 +385,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionKey: "agent:lossless:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
@@ -597,25 +396,38 @@ describe("maybeCompactCodexAppServerSession", () => {
},
},
agents: {
defaults: {
compaction: {
model: "openai/gpt-5.4",
provider: "lossless-claw",
list: [
{
id: "lossless",
compaction: {
model: "openai/gpt-5.4",
provider: "lossless-claw",
},
},
},
],
},
},
});
expect(warn).not.toHaveBeenCalledWith(
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
expect.anything(),
{
sessionId: "session-1",
sessionKey: "agent:lossless:session-1",
ignoredConfig: [
"agents.list.lossless.compaction.model",
"agents.list.lossless.compaction.provider",
],
},
);
warn.mockRestore();
});
it("does not warn for inherited legacy Lossless provider when the Lossless slot is active", async () => {
it("warns for inherited legacy Lossless provider when the Lossless slot is active", async () => {
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const contextEngine: ContextEngine = {
info: { id: "lcm", name: "Lossless Context Manager", ownsCompaction: true },
@@ -626,7 +438,7 @@ describe("maybeCompactCodexAppServerSession", () => {
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:nik:session-1",
sessionKey: "agent:lossless-child:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
@@ -644,7 +456,7 @@ describe("maybeCompactCodexAppServerSession", () => {
},
list: [
{
id: "nik",
id: "lossless-child",
compaction: {
model: "openai/gpt-5.4-mini",
},
@@ -654,9 +466,17 @@ describe("maybeCompactCodexAppServerSession", () => {
},
});
expect(warn).not.toHaveBeenCalledWith(
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(warn).toHaveBeenCalledWith(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
expect.anything(),
{
sessionId: "session-1",
sessionKey: "agent:lossless-child:session-1",
ignoredConfig: [
"agents.defaults.compaction.provider",
"agents.list.lossless-child.compaction.model",
],
},
);
warn.mockRestore();
});
@@ -688,8 +508,9 @@ describe("maybeCompactCodexAppServerSession", () => {
expect(factory).not.toHaveBeenCalled();
});
it("runs owning context-engine compaction and invalidates the Codex thread binding", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
it("forwards compaction to native Codex even when a context engine owns compaction", async () => {
const fake = createFakeCodexClient();
setCodexAppServerClientFactoryForTest(async () => fake.client);
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
@@ -697,8 +518,7 @@ describe("maybeCompactCodexAppServerSession", () => {
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
details: { engine: "lossless-claw" },
tokensBefore: 123,
},
}));
const maintain = vi.fn(
@@ -716,399 +536,67 @@ describe("maybeCompactCodexAppServerSession", () => {
maintain,
};
const pendingResult = maybeCompactCodexAppServerSession({
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
currentTokenCount: 123,
trigger: "manual",
}),
);
expect(fake.request).toHaveBeenCalledWith("thread/compact/start", { threadId: "thread-1" });
expect(result.ok).toBe(true);
expect(result.compacted).toBe(false);
expect(compactDetails(result)).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
});
expect(compact).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(await readCodexAppServerBinding(sessionFile)).toMatchObject({
threadId: "thread-1",
});
});
it("requires a Codex binding instead of delegating to an owning context engine", async () => {
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 123,
},
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
contextTokenBudget: 777,
contextEngineRuntimeContext: { workspaceDir: tempDir, provider: "codex" },
currentTokenCount: 123,
trigger: "manual",
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
expect(result.result?.firstKeptEntryId).toBe("entry-1");
expect(result.result?.tokensBefore).toBe(55);
const details = compactDetails(result);
expect(details.engine).toBe("lossless-claw");
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(compact).toHaveBeenCalledTimes(1);
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
tokenBudget: 777,
currentTokenCount: 123,
compactionTarget: "threshold",
customInstructions: undefined,
force: true,
runtimeContext: { workspaceDir: tempDir, provider: "codex" },
abortSignal: expect.any(AbortSignal),
}),
);
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
| {
sessionId?: string;
sessionKey?: string;
sessionFile?: string;
runtimeContext?: { workspaceDir?: string; provider?: string };
}
| undefined;
expect(maintainParams?.sessionId).toBe("session-1");
expect(maintainParams?.sessionKey).toBe("agent:main:session-1");
expect(maintainParams?.sessionFile).toBe(sessionFile);
expect(maintainParams?.runtimeContext?.workspaceDir).toBe(tempDir);
expect(maintainParams?.runtimeContext?.provider).toBe("codex");
expect(info).toHaveBeenCalledWith(
"starting context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
tokenBudget: 777,
currentTokenCount: 123,
trigger: "manual",
compactionTarget: "threshold",
force: true,
}),
);
expect(info).toHaveBeenCalledWith(
"completed context-engine-owned Codex app-server compaction",
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
engineId: "lossless-claw",
ok: true,
compacted: true,
codexThreadBindingInvalidated: true,
}),
);
});
it("honors explicit force for budget-triggered owning context-engine compaction", async () => {
const info = vi.spyOn(embeddedAgentLog, "info").mockImplementation(() => undefined);
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 900,
tokensAfter: 100,
},
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
contextTokenBudget: 777,
currentTokenCount: 900,
trigger: "budget",
force: true,
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
compactionTarget: "budget",
force: true,
}),
);
expect(info).toHaveBeenCalledWith(
"starting context-engine-owned Codex app-server compaction",
expect.objectContaining({
trigger: "budget",
compactionTarget: "budget",
force: true,
}),
);
});
it("adopts successor transcript handles after owning context-engine compaction", async () => {
const sessionFile = await writeTestBinding();
const successorFile = path.join(tempDir, "session.compacted.jsonl");
await writeCodexAppServerBinding(successorFile, {
threadId: "thread-successor",
cwd: tempDir,
});
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
sessionId: "session-1-compacted",
sessionFile: successorFile,
},
}));
const maintain = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["maintain"]>>[0]) => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}),
);
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = requireCompactResult(
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.sessionId).toBe("session-1-compacted");
expect(result.result?.sessionFile).toBe(successorFile);
expect(await readCodexAppServerBinding(sessionFile)).toBeUndefined();
expect(await readCodexAppServerBinding(successorFile)).toBeUndefined();
expect(maintain).toHaveBeenCalledTimes(1);
const [maintainCall] = maintain.mock.calls[0] ?? [];
const maintainParams = maintainCall as
| {
sessionId?: string;
sessionFile?: string;
}
| undefined;
expect(maintainParams?.sessionId).toBe("session-1-compacted");
expect(maintainParams?.sessionFile).toBe(successorFile);
});
it("returns context-engine compaction success when maintenance fails", async () => {
const sessionFile = await writeTestBinding();
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 55,
},
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain: vi.fn(async () => {
throw new Error("maintenance boom");
}),
};
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
});
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.result?.summary).toBe("engine summary");
const details = compactDetails(result);
expect(details.codexThreadBindingInvalidated).toBe(true);
expect(compact).toHaveBeenCalledTimes(1);
});
it("does not require a Codex binding when the owning context engine compacts", async () => {
const compact = vi.fn(async () => ({
ok: true,
compacted: true,
result: {
summary: "engine summary",
firstKeptEntryId: "entry-1",
tokensBefore: 8,
},
}));
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
maintain,
};
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
});
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.compacted).toBe(true);
expect(compactResult.result?.summary).toBe("engine summary");
expect(compact).toHaveBeenCalledTimes(1);
expect(maintain).toHaveBeenCalledTimes(1);
});
it("does not run context-engine maintenance when owning compaction does not compact", async () => {
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact: vi.fn(async () => ({
ok: true,
compacted: false,
reason: "below threshold",
})),
maintain,
};
const result = await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile: path.join(tempDir, "missing-binding.jsonl"),
workspaceDir: tempDir,
contextEngine,
});
const compactResult = requireCompactResult(result);
expect(compactResult.ok).toBe(true);
expect(compactResult.compacted).toBe(false);
expect(compactResult.reason).toBe("below threshold");
expect(maintain).not.toHaveBeenCalled();
});
describe("owning context-engine compaction safety timeout", () => {
afterEach(() => {
vi.useRealTimers();
});
it("bounds a hung owning context-engine compact() and reports a clean ok:false", async () => {
const sessionFile = await writeTestBinding();
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
vi.useFakeTimers();
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
// 1 s host-resolved compaction timeout.
config: { agents: { defaults: { compaction: { timeoutSeconds: 1 } } } },
});
await vi.advanceTimersByTimeAsync(1_000);
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain("timed out");
expect(compact).toHaveBeenCalledTimes(1);
expect(vi.getTimerCount()).toBe(0);
});
it("threads a composed caller abort signal into the owning context-engine compact()", async () => {
const sessionFile = await writeTestBinding();
const controller = new AbortController();
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
ok: true,
compacted: false,
reason: "below threshold",
}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
await maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
abortSignal: controller.signal,
});
expect(compact).toHaveBeenCalledTimes(1);
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
});
it("aborts a hung owning context-engine compact() when the caller signal fires", async () => {
const sessionFile = await writeTestBinding();
const controller = new AbortController();
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
const contextEngine: ContextEngine = {
info: { id: "lossless-claw", name: "Lossless Claw", ownsCompaction: true },
assemble: vi.fn() as never,
ingest: vi.fn() as never,
compact,
};
const pendingResult = maybeCompactCodexAppServerSession({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
workspaceDir: tempDir,
contextEngine,
abortSignal: controller.signal,
});
controller.abort(new Error("run aborted"));
const result = requireCompactResult(await pendingResult);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toContain("run aborted");
expect(compact).toHaveBeenCalledTimes(1);
expect(result).toMatchObject({
ok: false,
compacted: false,
failure: { reason: "missing_thread_binding" },
});
expect(compact).not.toHaveBeenCalled();
});
});
@@ -1121,14 +609,17 @@ function createFakeCodexClient(): {
const handlers = new Set<(notification: CodexServerNotification) => void>();
const request = vi.fn(async () => ({}));
const close = vi.fn();
const addNotificationHandler = vi.fn(
(handler: (notification: CodexServerNotification) => void) => {
handlers.add(handler);
return () => handlers.delete(handler);
},
);
return {
client: {
request,
close,
addNotificationHandler(handler: (notification: CodexServerNotification) => void) {
handlers.add(handler);
return () => handlers.delete(handler);
},
addNotificationHandler,
} as unknown as CodexAppServerClient,
request,
close,
+38 -518
View File
@@ -1,11 +1,5 @@
import {
compactContextEngineWithSafetyTimeout,
embeddedAgentLog,
formatErrorMessage,
isActiveHarnessContextEngine,
resolveCompactionTimeoutMs,
resolveContextEngineOwnerPluginId,
runHarnessContextEngineMaintenance,
type CompactEmbeddedPiSessionParams,
type EmbeddedPiCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
@@ -13,198 +7,27 @@ import {
defaultCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
import type { CodexAppServerClient, CodexServerNotificationHandler } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import { isJsonObject, type CodexServerNotification, type JsonObject } from "./protocol.js";
import type { JsonObject } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import { clearCodexAppServerBinding, readCodexAppServerBinding } from "./session-binding.js";
type CodexNativeCompactionCompletion = {
signal: "thread/compacted" | "item/completed";
turnId?: string;
itemId?: string;
tokensAfter?: number;
};
type CodexNativeCompactionWaiter = {
promise: Promise<CodexNativeCompactionCompletion>;
startTimeout: () => void;
cancel: () => void;
};
const DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS = 5 * 60 * 1000;
const CODEX_COMPACTION_TOKEN_USAGE_GRACE_MS = 250;
const MAX_CODEX_NATIVE_COMPACTION_ATTEMPTS = 2;
const warnedIgnoredCompactionOverrides = new Set<string>();
class CodexNativeCompactionTimeoutError extends Error {
constructor(readonly threadId: string) {
super(`timed out waiting for codex app-server compaction for ${threadId}`);
}
}
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
): Promise<EmbeddedPiCompactResult | undefined> {
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
if (activeContextEngine?.info.ownsCompaction) {
return await compactOwningContextEngine(params, activeContextEngine);
}
warnIfIgnoringOpenClawCompactionOverrides(params);
const nativeResult = await compactCodexNativeThread(params, options);
if (activeContextEngine && nativeResult?.ok && nativeResult.compacted) {
try {
await runHarnessContextEngineMaintenance({
contextEngine: activeContextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
reason: "compaction",
runtimeContext: params.contextEngineRuntimeContext,
config: params.config,
});
} catch (error) {
embeddedAgentLog.warn("context engine compaction maintenance failed after Codex compaction", {
sessionId: params.sessionId,
engineId: activeContextEngine.info.id,
error: formatErrorMessage(error),
});
}
}
return nativeResult;
}
async function compactOwningContextEngine(
params: CompactEmbeddedPiSessionParams,
contextEngine: NonNullable<CompactEmbeddedPiSessionParams["contextEngine"]>,
): Promise<EmbeddedPiCompactResult> {
const compactionTarget = params.trigger === "manual" ? "threshold" : "budget";
const force = params.force === true || params.trigger === "manual";
embeddedAgentLog.info("starting context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
trigger: params.trigger,
compactionTarget,
force,
});
let result: Awaited<ReturnType<typeof contextEngine.compact>>;
try {
// Bound the plugin-owned compaction with the same finite safety timeout
// that protects native runtime compaction, and thread the caller's abort
// signal through, so a slow/hung plugin compact() cannot hang the Codex
// compaction lane indefinitely. A timeout/abort (or any thrown error) is
// converted to a clean { ok: false } result by the catch below.
result = await compactContextEngineWithSafetyTimeout(
contextEngine,
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionFile: params.sessionFile,
tokenBudget: params.contextTokenBudget,
currentTokenCount: params.currentTokenCount,
compactionTarget,
customInstructions: params.customInstructions,
force,
runtimeContext: params.contextEngineRuntimeContext,
},
resolveCompactionTimeoutMs(params.config),
params.abortSignal,
);
} catch (error) {
embeddedAgentLog.warn("context-engine-owned Codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
return {
ok: false,
compacted: false,
reason: `context engine compaction failed: ${formatErrorMessage(error)}`,
};
}
if (result.ok && result.compacted) {
const compactedSessionId = result.result?.sessionId ?? params.sessionId;
const compactedSessionFile = result.result?.sessionFile ?? params.sessionFile;
try {
await runHarnessContextEngineMaintenance({
contextEngine,
sessionId: compactedSessionId,
sessionKey: params.sessionKey,
sessionFile: compactedSessionFile,
reason: "compaction",
runtimeContext: params.contextEngineRuntimeContext,
config: params.config,
});
} catch (error) {
embeddedAgentLog.warn("context engine compaction maintenance failed", {
sessionId: compactedSessionId,
engineId: contextEngine.info.id,
error: formatErrorMessage(error),
});
}
await clearCodexAppServerBinding(params.sessionFile, { config: params.config });
if (compactedSessionFile !== params.sessionFile) {
await clearCodexAppServerBinding(compactedSessionFile, { config: params.config });
}
}
embeddedAgentLog.info("completed context-engine-owned Codex app-server compaction", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
engineId: contextEngine.info.id,
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
codexThreadBindingInvalidated: result.ok && result.compacted,
});
return {
ok: result.ok,
compacted: result.compacted,
reason: result.reason,
result: result.result
? {
...result.result,
summary: result.result.summary ?? "",
firstKeptEntryId: result.result.firstKeptEntryId ?? "",
details: mergeContextEngineCompactionDetails(result.result.details, {
codexThreadBindingInvalidated: result.ok && result.compacted,
}),
}
: result.ok && result.compacted
? {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
details: { codexThreadBindingInvalidated: true },
}
: undefined,
};
}
function mergeContextEngineCompactionDetails(
details: unknown,
extra: Record<string, unknown>,
): unknown {
if (details && typeof details === "object" && !Array.isArray(details)) {
return {
...(details as Record<string, unknown>),
...extra,
};
}
return extra;
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point is only for explicit/manual compaction requests. OpenClaw
// starts native Codex compaction for the bound thread and returns immediately;
// Codex reports and applies the compaction inside its own app-server session.
return compactCodexNativeThread(params, options);
}
function warnIfIgnoringOpenClawCompactionOverrides(params: CompactEmbeddedPiSessionParams): void {
const activeContextEngine = isActiveHarnessContextEngine(params.contextEngine)
? params.contextEngine
: undefined;
const ignoredConfig = readIgnoredCompactionOverridePaths(params, activeContextEngine);
const ignoredConfig = readIgnoredCompactionOverridePaths(params);
if (ignoredConfig.length === 0) {
return;
}
@@ -223,21 +46,8 @@ function warnIfIgnoringOpenClawCompactionOverrides(params: CompactEmbeddedPiSess
);
}
function readIgnoredCompactionOverridePaths(
params: CompactEmbeddedPiSessionParams,
activeContextEngine?: CompactEmbeddedPiSessionParams["contextEngine"],
): string[] {
function readIgnoredCompactionOverridePaths(params: CompactEmbeddedPiSessionParams): string[] {
const ignored = new Set<string>();
const configuredContextEngine = readStringPath(params.config, [
"plugins",
"slots",
"contextEngine",
]);
const runtimeContextEnginePlugin =
typeof params.contextEngineRuntimeContext?.contextEnginePluginId === "string"
? params.contextEngineRuntimeContext.contextEnginePluginId.trim()
: "";
const activeContextEnginePlugin = resolveContextEngineOwnerPluginId(activeContextEngine);
for (const entry of readCompactionOverrideEntries(params)) {
const localProvider =
typeof entry.record.provider === "string" ? entry.record.provider.trim() : "";
@@ -245,20 +55,11 @@ function readIgnoredCompactionOverridePaths(
!localProvider && typeof entry.inheritedRecord?.provider === "string"
? entry.inheritedRecord.provider.trim()
: "";
const provider = localProvider || inheritedProvider;
const providerPath = localProvider
? `${entry.path}.compaction.provider`
: inheritedProvider && entry.inheritedPath
? `${entry.inheritedPath}.compaction.provider`
: undefined;
const activeLosslessContextEngine =
provider.toLowerCase() === "lossless-claw" &&
(activeContextEnginePlugin === "lossless-claw" ||
runtimeContextEnginePlugin.toLowerCase() === "lossless-claw" ||
configuredContextEngine?.toLowerCase() === "lossless-claw");
if (activeLosslessContextEngine) {
continue;
}
if (typeof entry.record.model === "string" && entry.record.model.trim()) {
ignored.add(`${entry.path}.compaction.model`);
}
@@ -322,14 +123,6 @@ function readRecord(value: unknown): Record<string, unknown> | undefined {
: undefined;
}
function readStringPath(value: unknown, path: readonly string[]): string | undefined {
let current = value;
for (const segment of path) {
current = readRecord(current)?.[segment];
}
return typeof current === "string" && current.trim() ? current.trim() : undefined;
}
async function compactCodexNativeThread(
params: CompactEmbeddedPiSessionParams,
options: { pluginConfig?: unknown; clientFactory?: CodexAppServerClientFactory } = {},
@@ -361,106 +154,54 @@ async function compactCodexNativeThread(
}
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
let completion: CodexNativeCompactionCompletion | undefined;
let attempt = 0;
for (attempt = 1; attempt <= MAX_CODEX_NATIVE_COMPACTION_ATTEMPTS; attempt += 1) {
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
const waiter = createCodexNativeCompactionWaiter(client, binding.threadId);
try {
await client.request("thread/compact/start", {
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
try {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (isCodexThreadNotFoundError(error)) {
await clearCodexAppServerBinding(params.sessionFile, { config: params.config });
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
});
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
attempt,
});
waiter.startTimeout();
completion = await waiter.promise;
break;
} catch (error) {
waiter.cancel();
if (isCodexThreadNotFoundError(error)) {
await clearCodexAppServerBinding(params.sessionFile, { config: params.config });
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
reason: formatCompactionError(error),
recovery: "stale_thread_binding",
});
}
if (
isCodexNativeCompactionTimeoutError(error, binding.threadId) &&
attempt < MAX_CODEX_NATIVE_COMPACTION_ATTEMPTS
) {
restartCodexAppServerAfterNativeCompactionTimeout(
client,
params,
binding.threadId,
attempt,
);
continue;
}
if (isCodexNativeCompactionTimeoutError(error, binding.threadId)) {
restartCodexAppServerAfterNativeCompactionTimeout(
client,
params,
binding.threadId,
attempt,
);
}
return {
ok: false,
compacted: false,
reason: formatCompactionError(error),
};
recovery: "stale_thread_binding",
});
}
}
if (!completion) {
embeddedAgentLog.warn("codex app-server compaction failed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
threadId: binding.threadId,
reason: formatCompactionError(error),
});
return {
ok: false,
compacted: false,
reason: `codex app-server compaction did not complete for ${binding.threadId}`,
reason: formatCompactionError(error),
};
}
embeddedAgentLog.info("completed codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
signal: completion.signal,
turnId: completion.turnId,
itemId: completion.itemId,
tokensAfter: completion.tokensAfter,
});
const resultDetails: JsonObject = {
backend: "codex-app-server",
threadId: binding.threadId,
signal: completion.signal,
signal: "thread/compact/start",
pending: true,
};
if (completion.turnId) {
resultDetails.turnId = completion.turnId;
}
if (completion.itemId) {
resultDetails.itemId = completion.itemId;
}
if (completion.tokensAfter !== undefined) {
resultDetails.tokenUsageSource = "thread/tokenUsage/updated";
}
if (attempt > 1) {
resultDetails.compactionAttempts = attempt;
resultDetails.recoveredAfterAppServerRestart = true;
}
return {
ok: true,
compacted: true,
compacted: false,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: params.currentTokenCount ?? 0,
...(completion.tokensAfter !== undefined ? { tokensAfter: completion.tokensAfter } : {}),
details: resultDetails,
},
};
@@ -496,227 +237,6 @@ function isCodexThreadNotFoundError(error: unknown): boolean {
return formatCompactionError(error).toLowerCase().includes("thread not found");
}
function isCodexNativeCompactionTimeoutError(error: unknown, threadId: string): boolean {
return error instanceof CodexNativeCompactionTimeoutError && error.threadId === threadId;
}
function restartCodexAppServerAfterNativeCompactionTimeout(
client: CodexAppServerClient,
params: CompactEmbeddedPiSessionParams,
threadId: string,
attempt: number,
): void {
embeddedAgentLog.warn("codex app-server compaction timed out; restarting app-server", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
threadId,
attempt,
maxAttempts: MAX_CODEX_NATIVE_COMPACTION_ATTEMPTS,
});
client.close();
}
function createCodexNativeCompactionWaiter(
client: CodexAppServerClient,
threadId: string,
): CodexNativeCompactionWaiter {
let settled = false;
let removeHandler: () => void = () => {};
let timeout: ReturnType<typeof setTimeout> | undefined;
let tokenUsageGraceTimeout: ReturnType<typeof setTimeout> | undefined;
let failWaiter: (error: Error) => void = () => {};
const promise = new Promise<CodexNativeCompactionCompletion>((resolve, reject) => {
const cleanup = (): void => {
removeHandler();
if (timeout) {
clearTimeout(timeout);
}
if (tokenUsageGraceTimeout) {
clearTimeout(tokenUsageGraceTimeout);
}
};
const complete = (completion: CodexNativeCompactionCompletion): void => {
if (settled) {
return;
}
settled = true;
cleanup();
resolve(completion);
};
const fail = (error: Error): void => {
if (settled) {
return;
}
settled = true;
cleanup();
reject(error);
};
let latestTokensAfter: number | undefined;
const completionWithLatestTokenUsage = (
completion: CodexNativeCompactionCompletion,
): CodexNativeCompactionCompletion =>
latestTokensAfter === undefined
? completion
: { ...completion, tokensAfter: latestTokensAfter };
const completeAfterTokenUsageGrace = (completion: CodexNativeCompactionCompletion): void => {
if (settled || tokenUsageGraceTimeout) {
return;
}
if (timeout) {
clearTimeout(timeout);
timeout = undefined;
}
tokenUsageGraceTimeout = setTimeout(
() => complete(completionWithLatestTokenUsage(observedCompletion ?? completion)),
CODEX_COMPACTION_TOKEN_USAGE_GRACE_MS,
);
tokenUsageGraceTimeout.unref?.();
};
failWaiter = fail;
let observedCompletion: CodexNativeCompactionCompletion | undefined;
const handler: CodexServerNotificationHandler = (notification) => {
const tokensAfter = readNativeCompactionTokenUsage(notification, threadId);
if (tokensAfter !== undefined) {
latestTokensAfter = tokensAfter;
if (observedCompletion) {
complete(completionWithLatestTokenUsage(observedCompletion));
return;
}
}
const completion = readNativeCompactionCompletion(notification, threadId);
if (completion) {
observedCompletion = completionWithLatestTokenUsage({
...observedCompletion,
...completion,
});
if (latestTokensAfter !== undefined) {
complete(observedCompletion);
return;
}
completeAfterTokenUsageGrace(observedCompletion);
}
};
removeHandler = client.addNotificationHandler(handler);
});
return {
promise,
startTimeout(): void {
if (settled || timeout) {
return;
}
timeout = setTimeout(() => {
failWaiter(new CodexNativeCompactionTimeoutError(threadId));
}, resolveCompactionWaitTimeoutMs());
timeout.unref?.();
},
cancel(): void {
if (settled) {
return;
}
settled = true;
removeHandler();
if (timeout) {
clearTimeout(timeout);
}
},
};
}
function readNativeCompactionTokenUsage(
notification: CodexServerNotification,
threadId: string,
): number | undefined {
const params = notification.params;
if (!isJsonObject(params) || readString(params, "threadId", "thread_id") !== threadId) {
return undefined;
}
if (notification.method !== "thread/tokenUsage/updated") {
return undefined;
}
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
const currentUsage = readCodexCurrentTokenUsage(tokenUsage) ?? readCodexCurrentTokenUsage(params);
return readCodexTotalTokens(currentUsage);
}
function readCodexCurrentTokenUsage(value: JsonObject | undefined): JsonObject | undefined {
if (!value) {
return undefined;
}
for (const key of [
"last",
"current",
"lastCall",
"lastCallUsage",
"lastTokenUsage",
"last_token_usage",
]) {
const usage = value[key];
if (isJsonObject(usage)) {
return usage;
}
}
return undefined;
}
function readCodexTotalTokens(value: JsonObject | undefined): number | undefined {
const totalTokens = value?.total_tokens ?? value?.totalTokens ?? value?.total;
return typeof totalTokens === "number" && Number.isFinite(totalTokens) && totalTokens > 0
? Math.floor(totalTokens)
: undefined;
}
function readNativeCompactionCompletion(
notification: CodexServerNotification,
threadId: string,
): CodexNativeCompactionCompletion | undefined {
const params = notification.params;
if (!isJsonObject(params) || readString(params, "threadId", "thread_id") !== threadId) {
return undefined;
}
if (notification.method === "thread/compacted") {
return {
signal: "thread/compacted",
turnId: readString(params, "turnId", "turn_id"),
};
}
if (notification.method !== "item/completed") {
return undefined;
}
const item = isJsonObject(params.item) ? params.item : undefined;
if (readString(item, "type") !== "contextCompaction") {
return undefined;
}
return {
signal: "item/completed",
turnId: readString(params, "turnId", "turn_id"),
itemId: readString(item, "id") ?? readString(params, "itemId", "item_id", "id"),
};
}
function resolveCompactionWaitTimeoutMs(): number {
const raw = process.env.OPENCLAW_CODEX_COMPACTION_WAIT_TIMEOUT_MS?.trim();
const parsed = raw ? Number.parseInt(raw, 10) : Number.NaN;
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
return DEFAULT_CODEX_COMPACTION_WAIT_TIMEOUT_MS;
}
function readString(params: JsonObject | undefined, ...keys: string[]): string | undefined {
if (!params) {
return undefined;
}
for (const key of keys) {
const value = params[key];
if (typeof value === "string") {
return value;
}
}
return undefined;
}
function formatCompactionError(error: unknown): string {
if (error instanceof Error) {
return error.message;
@@ -877,9 +877,8 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
});
it("retries a resumed context-engine thread on a fresh Codex thread after early context overflow", async () => {
it("retries a resumed context-engine thread on a fresh Codex thread without plugin compaction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const successorFile = path.join(tempDir, "session.compacted.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
assistantMessage("pre-compaction context", Date.now()) as never,
@@ -900,12 +899,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
},
});
let epoch = "epoch-before";
const compact = vi.fn(async () => {
epoch = "epoch-after";
SessionManager.open(successorFile).appendMessage(
assistantMessage("successor compacted context", Date.now()) as never,
);
return {
ok: true,
compacted: true,
@@ -914,7 +908,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
firstKeptEntryId: "entry-1",
tokensBefore: 10,
sessionId: "session-1-compacted",
sessionFile: successorFile,
},
};
});
@@ -922,12 +915,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[0]) => ({
messages: [
...messages,
assistantMessage(`context ${epoch}`, 10),
assistantMessage("context epoch-before", 10),
userMessage(prompt ?? "", 11),
],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch },
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-before" },
}),
);
const contextEngine = createContextEngine({ assemble, compact });
@@ -975,40 +968,18 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const result = await run;
expect(result.assistantTexts).toContain("fresh answer");
expect(compact).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
sessionFile,
tokenBudget: 400_000,
currentTokenCount: 400_000,
compactionTarget: "threshold",
force: true,
}),
);
expect(assemble).toHaveBeenCalledTimes(2);
const retryAssembleParams = assemble.mock.calls[1]?.[0];
expect(retryAssembleParams?.messages.map((message) => message.role)).toEqual(["assistant"]);
const retryAssembleMessageTexts = retryAssembleParams?.messages.map((message) => {
if (!("content" in message) || !Array.isArray(message.content)) {
return "";
}
const firstContent = message.content[0];
return typeof firstContent === "object" && firstContent !== null && "text" in firstContent
? firstContent.text
: "";
});
expect(retryAssembleMessageTexts).toEqual(["successor compacted context"]);
expect(compact).not.toHaveBeenCalled();
expect(assemble).toHaveBeenCalledTimes(1);
const retryInputText = getRequestInputTextAt(harness, -1);
expect(retryInputText).toContain("successor compacted context");
expect(retryInputText).not.toContain("pre-compaction context");
const savedBinding = await readCodexAppServerBinding(successorFile);
expect(retryInputText).toBe("hello");
expect(retryInputText).not.toContain("successor compacted context");
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-after");
expect(savedBinding?.contextEngine?.projection?.epoch).toBe("epoch-before");
});
it("compacts over-budget rendered context-engine prompts before Codex turn/start", async () => {
it("does not pre-compact over-budget rendered context-engine prompts before Codex turn/start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
@@ -1025,20 +996,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
compacted: true,
result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 },
}));
const assemble = vi
.fn<ContextEngine["assemble"]>()
.mockResolvedValueOnce({
messages: Array.from({ length: 8 }, (_, index) =>
toolResultMessage(hugePayload, index + 1),
),
estimatedTokens: 100_000,
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-before" },
})
.mockResolvedValueOnce({
messages: [assistantMessage("successor compacted context", 2) as never],
estimatedTokens: 100,
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-after" },
});
const assemble = vi.fn<ContextEngine["assemble"]>().mockResolvedValue({
messages: Array.from({ length: 8 }, (_, index) => toolResultMessage(hugePayload, index + 1)),
estimatedTokens: 100_000,
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-before" },
});
const contextEngine = createContextEngine({ assemble, compact });
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
@@ -1048,22 +1010,58 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(compact).toHaveBeenCalledTimes(1);
expect(assemble).toHaveBeenCalledTimes(2);
expect(compact).not.toHaveBeenCalled();
expect(assemble).toHaveBeenCalledTimes(1);
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
const inputText = getRequestInputText(harness);
expect(inputText).toContain("successor compacted context");
expect(inputText).not.toContain("0123456789abcdef");
expect(inputText).toContain("0123456789abcdef");
await harness.completeTurn();
const result = await run;
expect(result.assistantTexts).toContain("final answer");
});
it("bounds a hung owning context-engine compaction during Codex overflow recovery", async () => {
it("fails first-turn Codex context overflow instead of falling back to OpenClaw compaction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const compact = vi.fn<ContextEngine["compact"]>(async () => ({
ok: true,
compacted: true,
result: { summary: "summary", firstKeptEntryId: "entry-1", tokensBefore: 100_000 },
}));
const assemble = vi.fn<ContextEngine["assemble"]>().mockResolvedValue({
messages: [assistantMessage("large projected context", 10)],
estimatedTokens: 100_000,
contextProjection: { mode: "thread_bootstrap", epoch: "epoch-before" },
});
const contextEngine = createContextEngine({ assemble, compact });
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw new Error("Codex ran out of room in the model's context window");
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 16_000;
await expect(runCodexAppServerAttempt(params)).rejects.toThrow(
"Codex ran out of room in the model's context window",
);
expect(compact).not.toHaveBeenCalled();
expect(assemble).toHaveBeenCalledTimes(1);
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
"thread/unsubscribe",
]);
});
it("does not call hung owning context-engine compaction during Codex overflow recovery", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
SessionManager.open(sessionFile).appendMessage(
@@ -1085,10 +1083,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
},
});
// Owning-engine compaction that never settles. Without the safety timeout
// the awaited compact() would hang the whole Codex overflow-recovery turn;
// with it the call is bounded and forced compaction reports failure so the
// run still proceeds on a fresh thread.
const compact = vi.fn<ContextEngine["compact"]>(() => new Promise(() => {}));
const assemble = vi.fn(
async ({ messages, prompt }: Parameters<ContextEngine["assemble"]>[0]) => ({
@@ -1118,11 +1112,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 400_000;
// 1 s host-resolved compaction timeout so the hung compact() is bounded
// well within the 5 s run timeout used by this harness.
params.config = {
agents: { defaults: { compaction: { timeoutSeconds: 1 } } },
} as EmbeddedRunAttemptParams["config"];
const run = runCodexAppServerAttempt(params);
await vi.waitFor(
@@ -1150,10 +1139,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const result = await run;
expect(result.assistantTexts).toContain("fresh answer");
expect(compact).toHaveBeenCalledTimes(1);
// The run-level abort signal is threaded into the owning-engine compact()
// so a cooperating engine can cancel its own in-flight work.
expect(compact.mock.calls[0]?.[0]?.abortSignal).toBeInstanceOf(AbortSignal);
expect(compact).not.toHaveBeenCalled();
});
it("keeps current inbound context at the front of the Codex context-engine prompt", async () => {
+3 -218
View File
@@ -12,12 +12,9 @@ import {
buildEmbeddedAttemptToolRunContext,
CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
clearActiveEmbeddedRun,
compactContextEngineWithSafetyTimeout,
embeddedAgentLog,
emitAgentEvent as emitGlobalAgentEvent,
estimateRenderedLlmBoundaryTokenPressure,
finalizeHarnessContextEngineTurn,
formatPrePromptPrecheckLog,
formatErrorMessage,
getBeforeToolCallPolicyDiagnosticState,
isActiveHarnessContextEngine,
@@ -26,7 +23,6 @@ import {
normalizeAgentRuntimeTools,
resolveAttemptSpawnWorkspaceDir,
resolveAgentHarnessBeforePromptBuildResult,
resolveCompactionTimeoutMs,
resolveModelAuthMode,
resolveContextEngineOwnerPluginId,
resolveSandboxContext,
@@ -37,8 +33,6 @@ import {
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
shouldPreemptivelyCompactBeforePrompt,
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
registerNativeHookRelay,
resolveBootstrapContextForRun,
setActiveEmbeddedRun,
@@ -94,7 +88,6 @@ import {
type CodexPluginConfig,
} from "./config.js";
import {
DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS,
projectContextEngineAssemblyForCodex,
resolveCodexContextEngineProjectionMaxChars,
resolveCodexContextEngineProjectionReserveTokens,
@@ -1042,16 +1035,6 @@ export async function runCodexAppServerAttempt(
sessionId: activeSessionId,
sessionFile: activeSessionFile,
});
const adoptContextEngineCompactionTranscript = (compactResult: {
result?: { sessionId?: string; sessionFile?: string };
}): void => {
if (compactResult.result?.sessionId) {
activeSessionId = compactResult.result.sessionId;
}
if (compactResult.result?.sessionFile) {
activeSessionFile = compactResult.result.sessionFile;
}
};
const startupAuthAccountCacheKey = await resolveCodexAppServerAuthAccountCacheKey({
authProfileId: startupAuthProfileId,
authProfileStore: params.authProfileStore,
@@ -1179,86 +1162,6 @@ export async function runCodexAppServerAttempt(
contextEnginePluginId: activeContextEnginePluginId,
tokenBudget: params.contextTokenBudget,
});
const forceContextEngineCompactionForCodexOverflow = async (
error: unknown,
options: { threadId?: string } = {},
): Promise<boolean> => {
if (!activeContextEngine?.info.ownsCompaction) {
return false;
}
embeddedAgentLog.warn(
"codex app-server context-engine prompt overflowed; forcing context-engine compaction",
{
sessionId: activeSessionId,
sessionKey: contextSessionKey,
...(options.threadId ? { threadId: options.threadId } : {}),
engineId: activeContextEngine.info.id,
tokenBudget: params.contextTokenBudget,
error: formatErrorMessage(error),
},
);
try {
const runtimeContext = buildActiveContextEngineRuntimeContext();
const overflowTokenCount = params.contextTokenBudget ?? params.contextWindowInfo?.tokens;
// Bound the plugin-owned compaction with the same finite safety timeout
// that protects native runtime compaction, and thread the run-level
// abort signal through, so a slow/hung plugin compact() cannot stall
// Codex overflow recovery indefinitely.
const compactResult = await compactContextEngineWithSafetyTimeout(
activeContextEngine,
{
sessionId: activeSessionId,
sessionKey: contextSessionKey,
sessionFile: activeSessionFile,
tokenBudget: params.contextTokenBudget,
force: true,
...(overflowTokenCount ? { currentTokenCount: overflowTokenCount } : {}),
compactionTarget: "threshold",
runtimeContext: overflowTokenCount
? {
...runtimeContext,
currentTokenCount: overflowTokenCount,
}
: runtimeContext,
},
resolveCompactionTimeoutMs(params.config),
runAbortController.signal,
);
embeddedAgentLog.info("codex app-server context-engine forced compaction result", {
sessionId: activeSessionId,
sessionKey: contextSessionKey,
engineId: activeContextEngine.info.id,
ok: compactResult.ok,
compacted: compactResult.compacted,
reason: compactResult.reason,
tokensBefore: compactResult.result?.tokensBefore,
tokensAfter: compactResult.result?.tokensAfter,
});
if (!compactResult.ok || !compactResult.compacted) {
return false;
}
adoptContextEngineCompactionTranscript(compactResult);
const maintenanceRuntimeContext = buildActiveContextEngineRuntimeContext();
await runHarnessContextEngineMaintenance({
contextEngine: activeContextEngine,
sessionId: activeSessionId,
sessionKey: contextSessionKey,
sessionFile: activeSessionFile,
reason: "compaction",
runtimeContext: maintenanceRuntimeContext,
config: params.config,
});
return true;
} catch (compactErr) {
embeddedAgentLog.warn("codex app-server context-engine forced compaction failed", {
sessionId: params.sessionId,
sessionKey: contextSessionKey,
engineId: activeContextEngine.info.id,
error: formatErrorMessage(compactErr),
});
return false;
}
};
if (activeContextEngine) {
await bootstrapHarnessContextEngine({
hadSessionFile,
@@ -1296,12 +1199,6 @@ export async function runCodexAppServerAttempt(
let developerInstructions = baseDeveloperInstructions;
let prePromptMessageCount = historyMessages.length;
let contextEngineProjection: CodexContextEngineThreadBootstrapProjection | undefined;
const resetCodexPromptInputs = () => {
promptText = params.prompt;
developerInstructions = baseDeveloperInstructions;
prePromptMessageCount = historyMessages.length;
contextEngineProjection = undefined;
};
const applyActiveContextEngineProjection = async (
decisionStartupBinding: CodexAppServerThreadBinding | undefined,
) => {
@@ -1412,9 +1309,6 @@ export async function runCodexAppServerAttempt(
const decorateCodexTurnPromptText = (prompt: string) =>
prependCodexOpenClawPromptContext(prompt, openClawPromptContext);
let codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
const refreshCodexTurnPromptText = () => {
codexTurnPromptText = decorateCodexTurnPromptText(promptBuild.prompt);
};
const buildCodexTurnCollaborationDeveloperInstructions = () =>
buildTurnCollaborationMode(params, {
turnScopedDeveloperInstructions: workspaceBootstrapContext.turnScopedDeveloperInstructions,
@@ -1426,109 +1320,6 @@ export async function runCodexAppServerAttempt(
promptBuild.developerInstructions,
buildCodexTurnCollaborationDeveloperInstructions(),
);
const rebuildPromptAfterContextEngineCompaction = async () => {
historyMessages =
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
resetCodexPromptInputs();
try {
await applyActiveContextEngineProjection(undefined);
} catch (assembleErr) {
embeddedAgentLog.warn(
"context engine assemble failed after forced compaction; using Codex baseline prompt",
{
error: formatErrorMessage(assembleErr),
},
);
}
promptBuild = await buildPromptFromCurrentInputs();
refreshCodexTurnPromptText();
};
const buildCodexProviderBoundaryPrecheck = () => {
const contextTokenBudget =
typeof params.contextTokenBudget === "number" && Number.isFinite(params.contextTokenBudget)
? Math.floor(params.contextTokenBudget)
: typeof params.contextWindowInfo?.tokens === "number" &&
Number.isFinite(params.contextWindowInfo.tokens)
? Math.floor(params.contextWindowInfo.tokens)
: undefined;
if (!contextTokenBudget || contextTokenBudget <= 0) {
return undefined;
}
const reserveTokens =
resolveCodexContextEngineProjectionReserveTokens({ config: params.config }) ??
DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS;
const renderedDeveloperInstructions = buildRenderedCodexDeveloperInstructions();
const renderedChars = codexTurnPromptText.length + renderedDeveloperInstructions.length;
return shouldPreemptivelyCompactBeforePrompt({
messages: historyMessages,
systemPrompt: renderedDeveloperInstructions,
prompt: codexTurnPromptText,
contextTokenBudget,
reserveTokens,
llmBoundaryTokenPressure: {
estimatedPromptTokens: estimateRenderedLlmBoundaryTokenPressure({
systemPrompt: renderedDeveloperInstructions,
prompt: codexTurnPromptText,
}),
source: "codex_app_server_rendered_prompt",
renderedChars,
},
});
};
const maybeCompactContextEngineForProviderBoundaryPrecheck = async () => {
if (!activeContextEngine?.info.ownsCompaction || !contextEngineProjection) {
return;
}
const precheck = buildCodexProviderBoundaryPrecheck();
if (!precheck) {
return;
}
embeddedAgentLog.debug(
formatPrePromptPrecheckLog({
result: precheck,
provider: params.provider,
modelId: params.modelId,
messageCount: historyMessages.length,
contextTokenBudget:
typeof params.contextTokenBudget === "number"
? params.contextTokenBudget
: (params.contextWindowInfo?.tokens ?? 0),
reserveTokens:
resolveCodexContextEngineProjectionReserveTokens({ config: params.config }) ??
DEFAULT_CODEX_PROJECTION_RESERVE_TOKENS,
...(contextSessionKey ? { sessionKey: contextSessionKey } : {}),
...(activeSessionId ? { sessionId: activeSessionId } : {}),
...(activeSessionFile ? { sessionFile: activeSessionFile } : {}),
}),
);
if (precheck.route === "fits") {
return;
}
const compacted = await forceContextEngineCompactionForCodexOverflow(
PREEMPTIVE_OVERFLOW_ERROR_TEXT,
);
if (!compacted) {
throw new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
}
await rebuildPromptAfterContextEngineCompaction();
const afterCompactionPrecheck = buildCodexProviderBoundaryPrecheck();
if (!afterCompactionPrecheck || afterCompactionPrecheck.route === "fits") {
return;
}
embeddedAgentLog.warn(
"codex app-server provider-boundary precheck still overflowed after compaction",
{
sessionId: activeSessionId,
sessionKey: contextSessionKey,
route: afterCompactionPrecheck.route,
estimatedPromptTokens: afterCompactionPrecheck.estimatedPromptTokens,
promptBudgetBeforeReserve: afterCompactionPrecheck.promptBudgetBeforeReserve,
overflowTokens: afterCompactionPrecheck.overflowTokens,
},
);
throw new Error(PREEMPTIVE_OVERFLOW_ERROR_TEXT);
};
await maybeCompactContextEngineForProviderBoundaryPrecheck();
const systemPromptReport = buildCodexSystemPromptReport({
attempt: params,
sessionKey: contextSessionKey,
@@ -2929,6 +2720,9 @@ export async function runCodexAppServerAttempt(
}) &&
restartContextEngineCodexThread
) {
// Do not try to pre-compact or summarize through OpenClaw here. Codex owns
// automatic compaction; OpenClaw may only discard a stale projection thread
// and let Codex start cleanly.
embeddedAgentLog.warn(
"codex app-server context-engine turn overflowed on resume; retrying with fresh thread",
{
@@ -2938,19 +2732,10 @@ export async function runCodexAppServerAttempt(
);
try {
const preRetrySessionFile = activeSessionFile;
const compactedForRetry = await forceContextEngineCompactionForCodexOverflow(
turnStartError,
{
threadId: thread.threadId,
},
);
await clearCodexAppServerBinding(preRetrySessionFile);
if (activeSessionFile !== preRetrySessionFile) {
await clearCodexAppServerBinding(activeSessionFile);
}
if (compactedForRetry) {
await rebuildPromptAfterContextEngineCompaction();
}
thread = await restartContextEngineCodexThread();
emitCodexAppServerEvent(params, {
stream: "codex_app_server.lifecycle",
+64 -367
View File
@@ -177,7 +177,7 @@ describe("runCliTurnCompactionLifecycle", () => {
expect(updatedEntry?.claudeCliSessionId).toBeUndefined();
});
it("routes OpenAI Codex harness CLI compaction through native harness compaction", async () => {
it("skips OpenClaw automatic CLI compaction for OpenAI Codex runtime sessions", async () => {
const sessionKey = "agent:main:codex";
const sessionId = "session-codex";
const sessionFile = path.join(tmpDir, "session-codex.jsonl");
@@ -197,8 +197,7 @@ describe("runCliTurnCompactionLifecycle", () => {
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const contextEngine = buildContextEngine({ compactCalls });
const resolveContextEngine = vi.fn(async () => contextEngine);
const resolveContextEngine = vi.fn(async () => buildContextEngine({ compactCalls }));
const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined);
const compactAgentHarnessSession = vi.fn(async () => ({
ok: true,
@@ -250,49 +249,65 @@ describe("runCliTurnCompactionLifecycle", () => {
model: "gpt-5.5",
});
expect(resolveContextEngine).toHaveBeenCalledTimes(1);
expect(applyPiAutoCompactionGuard).toHaveBeenCalledWith(
expect.objectContaining({
contextEngineInfo: contextEngine.info,
}),
);
expect(ensureSelectedAgentHarnessPlugin).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
modelId: "gpt-5.5",
sessionKey,
agentHarnessRuntimeOverride: "codex",
}),
);
expect(applyPiAutoCompactionGuard.mock.invocationCallOrder[0] ?? 0).toBeLessThan(
compactAgentHarnessSession.mock.invocationCallOrder[0] ?? 0,
);
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
const compactAgentHarnessSessionCalls = compactAgentHarnessSession.mock
.calls as unknown as Array<[Record<string, unknown>]>;
expect(compactAgentHarnessSessionCalls[0]?.[0]).toMatchObject({
expect(resolveContextEngine).not.toHaveBeenCalled();
expect(applyPiAutoCompactionGuard).not.toHaveBeenCalled();
expect(ensureSelectedAgentHarnessPlugin).not.toHaveBeenCalled();
expect(compactAgentHarnessSession).not.toHaveBeenCalled();
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).not.toHaveBeenCalled();
expect(updatedEntry).toBe(sessionEntry);
});
it("skips OpenClaw automatic CLI compaction when OpenAI resolves to Codex by policy", async () => {
const sessionKey = "agent:main:codex-policy";
const sessionId = "session-codex-policy";
const sessionFile = path.join(tmpDir, "session-codex-policy.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-policy.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const openSessionManager = vi.fn(() => {
throw new Error("OpenClaw must not inspect Codex transcripts for automatic compaction");
});
const resolveContextEngine = vi.fn();
const ensureSelectedAgentHarnessPlugin = vi.fn();
const compactAgentHarnessSession = vi.fn();
setCliCompactionTestDeps({
openSessionManager: openSessionManager as never,
resolveContextEngine: resolveContextEngine as never,
ensureSelectedAgentHarnessPlugin: ensureSelectedAgentHarnessPlugin as never,
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
});
const updatedEntry = await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionFile,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "openai",
model: "gpt-5.5",
contextTokenBudget: 1_000,
currentTokenCount: 950,
contextEngine,
agentHarnessId: "codex",
trigger: "budget",
force: true,
});
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
expect.objectContaining({
provider: "openai",
sessionKey,
tokensAfter: 100,
}),
);
expect(updatedEntry?.compactionCount).toBe(1);
expect(openSessionManager).not.toHaveBeenCalled();
expect(resolveContextEngine).not.toHaveBeenCalled();
expect(ensureSelectedAgentHarnessPlugin).not.toHaveBeenCalled();
expect(compactAgentHarnessSession).not.toHaveBeenCalled();
expect(updatedEntry).toBe(sessionEntry);
});
it("ignores stale native harness ids when the active provider no longer matches", async () => {
@@ -359,240 +374,6 @@ describe("runCliTurnCompactionLifecycle", () => {
expect(compactCalls).toHaveLength(1);
});
it("surfaces nonrecoverable native harness CLI compaction failures", async () => {
const sessionKey = "agent:main:codex-native-failure";
const sessionId = "session-codex-native-failure";
const sessionFile = path.join(tmpDir, "session-codex-native-failure.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-native-failure.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined);
const compactAgentHarnessSession = vi.fn(async () => ({
ok: false,
compacted: false,
reason: "timed out waiting for codex app-server compaction",
}));
const recordCliCompactionInStore = vi.fn();
setCliCompactionTestDeps({
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
ensureSelectedAgentHarnessPlugin,
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
createPreparedEmbeddedPiSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
recordCliCompactionInStore,
});
await expect(
runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
model: "gpt-5.5",
}),
).rejects.toThrow(
"CLI native harness compaction failed for codex/gpt-5.5: timed out waiting for codex app-server compaction",
);
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
expect(compactCalls).toHaveLength(0);
expect(recordCliCompactionInStore).not.toHaveBeenCalled();
});
it("does not fall back when native harness compaction returns no result", async () => {
const sessionKey = "agent:main:codex-native-empty";
const sessionId = "session-codex-native-empty";
const sessionFile = path.join(tmpDir, "session-codex-native-empty.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-native-empty.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
setCliCompactionTestDeps({
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
ensureSelectedAgentHarnessPlugin: vi.fn(async () => undefined),
maybeCompactAgentHarnessSession: vi.fn(async () => undefined) as never,
createPreparedEmbeddedPiSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
});
await expect(
runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
model: "gpt-5.5",
}),
).rejects.toThrow(
"CLI native harness compaction failed for codex/gpt-5.5: native harness compaction did not reduce context",
);
expect(compactCalls).toHaveLength(0);
});
it("passes owning context engines into native harness CLI compaction", async () => {
const sessionKey = "agent:main:codex-owned-engine";
const sessionId = "session-codex-owned-engine";
const sessionFile = path.join(tmpDir, "session-codex-owned-engine.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-owned-engine.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const contextEngine = {
...buildContextEngine({ compactCalls }),
info: {
id: "lossless-claw",
name: "Lossless Claw",
ownsCompaction: true,
},
} satisfies ContextEngine;
const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined);
const compactAgentHarnessSession = vi.fn(async (compactParams) => {
expect(compactParams.contextEngine).toBe(contextEngine);
expect(compactParams.contextEngineRuntimeContext).toMatchObject({
currentTokenCount: 950,
tokenBudget: 1_000,
trigger: "cli_native_budget",
});
return {
ok: true,
compacted: true,
result: {
summary: "engine-owned",
firstKeptEntryId: "entry-1",
tokensBefore: 950,
tokensAfter: 42,
sessionId: "session-codex-owned-engine-rotated",
sessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"),
},
};
});
const recordCliCompactionInStore = vi.fn(async () => ({
...sessionEntry,
compactionCount: 1,
}));
setCliCompactionTestDeps({
resolveContextEngine: async () => contextEngine,
ensureSelectedAgentHarnessPlugin,
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
createPreparedEmbeddedPiSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
recordCliCompactionInStore,
});
await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
model: "gpt-5.5",
});
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
sessionKey,
tokensAfter: 42,
newSessionId: "session-codex-owned-engine-rotated",
newSessionFile: path.join(tmpDir, "session-codex-owned-engine-rotated.jsonl"),
}),
);
});
it("falls back to context-engine compaction when a pinned harness has no native compactor", async () => {
const sessionKey = "agent:main:external-harness";
const sessionId = "session-external-harness";
@@ -675,95 +456,11 @@ describe("runCliTurnCompactionLifecycle", () => {
expect(updatedEntry?.compactionCount).toBe(1);
});
it("falls back to context-engine compaction when Codex native binding is stale", async () => {
const sessionKey = "agent:main:codex-stale-binding";
const sessionId = "session-codex-stale-binding";
const sessionFile = path.join(tmpDir, "session-codex-stale-binding.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-stale-binding.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
sessionId,
updatedAt: Date.now(),
sessionFile,
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
const compactCalls: Array<Parameters<ContextEngine["compact"]>[0]> = [];
const ensureSelectedAgentHarnessPlugin = vi.fn(async () => undefined);
const compactAgentHarnessSession = vi.fn(async () => ({
ok: false,
compacted: false,
reason: "thread not found: thread-1",
failure: {
reason: "stale_thread_binding",
},
}));
const maintenance = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
const recordCliCompactionInStore = vi.fn(async () => ({
...sessionEntry,
compactionCount: 1,
}));
setCliCompactionTestDeps({
resolveContextEngine: async () => buildContextEngine({ compactCalls }),
ensureSelectedAgentHarnessPlugin,
maybeCompactAgentHarnessSession: compactAgentHarnessSession as never,
createPreparedEmbeddedPiSettingsManager: async () => ({
getCompactionReserveTokens: () => 200,
getCompactionKeepRecentTokens: () => 0,
applyOverrides: () => {},
}),
shouldPreemptivelyCompactBeforePrompt: () => ({
route: "fits",
shouldCompact: false,
estimatedPromptTokens: 600,
promptBudgetBeforeReserve: 800,
overflowTokens: 0,
toolResultReducibleChars: 0,
effectiveReserveTokens: 200,
}),
resolveLiveToolResultMaxChars: () => 20_000,
runContextEngineMaintenance: maintenance,
recordCliCompactionInStore,
});
const updatedEntry = await runCliTurnCompactionLifecycle({
cfg: {} as OpenClawConfig,
sessionId,
sessionKey,
sessionEntry,
sessionStore,
storePath,
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
model: "gpt-5.5",
});
expect(compactAgentHarnessSession).toHaveBeenCalledTimes(1);
expect(compactCalls).toHaveLength(1);
expect(maintenance).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
expect.objectContaining({
provider: "codex",
sessionKey,
tokensAfter: undefined,
}),
);
expect(updatedEntry?.compactionCount).toBe(1);
});
it("keeps successful context-engine fallback when post-compaction maintenance fails", async () => {
const sessionKey = "agent:main:codex-stale-maintenance";
const sessionId = "session-codex-stale-maintenance";
const sessionFile = path.join(tmpDir, "session-codex-stale-maintenance.jsonl");
const storePath = path.join(tmpDir, "sessions-codex-stale-maintenance.json");
const sessionKey = "agent:main:external-harness-stale-maintenance";
const sessionId = "session-external-harness-stale-maintenance";
const sessionFile = path.join(tmpDir, "session-external-harness-stale-maintenance.jsonl");
const storePath = path.join(tmpDir, "sessions-external-harness-stale-maintenance.json");
await writeSessionFile({ sessionFile, sessionId });
const sessionEntry: SessionEntry = {
@@ -773,7 +470,7 @@ describe("runCliTurnCompactionLifecycle", () => {
contextTokens: 1_000,
totalTokens: 950,
totalTokensFresh: true,
agentHarnessId: "codex",
agentHarnessId: "external-harness",
};
const sessionStore: Record<string, SessionEntry> = { [sessionKey]: sessionEntry };
await fs.writeFile(storePath, JSON.stringify(sessionStore, null, 2), "utf-8");
@@ -824,14 +521,14 @@ describe("runCliTurnCompactionLifecycle", () => {
sessionAgentId: "main",
workspaceDir: tmpDir,
agentDir: tmpDir,
provider: "codex",
model: "gpt-5.5",
provider: "external-harness",
model: "model",
});
expect(compactCalls).toHaveLength(1);
expect(maintenance).toHaveBeenCalledTimes(1);
expect(recordCliCompactionInStore).toHaveBeenCalledWith(
expect.objectContaining({ provider: "codex", sessionKey }),
expect.objectContaining({ provider: "external-harness", sessionKey }),
);
expect(updatedEntry?.compactionCount).toBe(1);
});
+68 -5
View File
@@ -7,6 +7,7 @@ import { ensureContextEnginesInitialized as ensureContextEnginesInitializedImpl
import { resolveContextEngine as resolveContextEngineImpl } from "../../context-engine/registry.js";
import type { ContextEngine } from "../../context-engine/types.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
import { resolveAgentHarnessPolicy } from "../harness/policy.js";
import { ensureSelectedAgentHarnessPlugin as ensureSelectedAgentHarnessPluginImpl } from "../harness/runtime-plugin.js";
import { maybeCompactAgentHarnessSession as maybeCompactAgentHarnessSessionImpl } from "../harness/selection.js";
import { buildEmbeddedCompactionRuntimeContext } from "../pi-embedded-runner/compaction-runtime-context.js";
@@ -181,6 +182,45 @@ function isRecoverableNativeHarnessCompactionFailure(
);
}
function isCodexNativeHarnessCompactionSession(
sessionEntry: SessionEntry,
provider: string,
): boolean {
const harnessId = sessionEntry.agentHarnessId?.trim().toLowerCase();
const providerId = provider.trim().toLowerCase();
return (
harnessId === "codex" &&
(providerId === "codex" || providerId === "openai" || providerId === "openai-codex")
);
}
function shouldSkipAutomaticCompactionForCodexRuntime(params: {
cfg: OpenClawConfig;
sessionEntry: SessionEntry;
sessionAgentId: string;
sessionKey: string;
provider: string;
model: string;
}): boolean {
const runtimeOverride = params.sessionEntry.agentRuntimeOverride?.trim().toLowerCase();
if (runtimeOverride && runtimeOverride !== "auto" && runtimeOverride !== "default") {
return runtimeOverride === "codex";
}
const harnessId = params.sessionEntry.agentHarnessId?.trim().toLowerCase();
if (harnessId) {
return isCodexNativeHarnessCompactionSession(params.sessionEntry, params.provider);
}
return (
resolveAgentHarnessPolicy({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: params.sessionAgentId,
sessionKey: params.sessionKey,
}).runtime === "codex"
);
}
function readAgentIdFromSessionKey(sessionKey: string): string | undefined {
const parts = sessionKey.trim().split(":");
return parts[0] === "agent" && parts[1]?.trim() ? parts[1].trim() : undefined;
@@ -401,8 +441,9 @@ async function compactNativeHarnessCliTranscript(params: {
if (!result?.compacted) {
const fallbackToContextEngine =
isUnsupportedNativeHarnessCompaction(result) ||
isRecoverableNativeHarnessCompactionFailure(result);
!isCodexNativeHarnessCompactionSession(params.sessionEntry, params.provider) &&
(isUnsupportedNativeHarnessCompaction(result) ||
isRecoverableNativeHarnessCompactionFailure(result));
log.warn(
`CLI native harness compaction did not reduce context for ${params.provider}/${params.model}: ${result?.reason ?? "nothing to compact"}`,
);
@@ -435,10 +476,32 @@ export async function runCliTurnCompactionLifecycle(params: {
thinkLevel?: Parameters<typeof buildEmbeddedCompactionRuntimeContext>[0]["thinkLevel"];
extraSystemPrompt?: string;
}): Promise<SessionEntry | undefined> {
const sessionFile = params.sessionEntry?.sessionFile;
const contextTokenBudget = resolvePositiveInteger(params.sessionEntry?.contextTokens);
const sessionEntry = params.sessionEntry;
const sessionFile = sessionEntry?.sessionFile;
const contextTokenBudget = resolvePositiveInteger(sessionEntry?.contextTokens);
if (!sessionFile || !contextTokenBudget) {
return params.sessionEntry;
return sessionEntry;
}
if (
shouldSkipAutomaticCompactionForCodexRuntime({
cfg: params.cfg,
sessionEntry,
sessionAgentId: params.sessionAgentId,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
})
) {
// Codex CLI/app-server runtimes own their automatic transcript compaction.
// Avoid resurrecting OpenClaw's paternalistic budget fallback here; explicit
// /compact or plugin compaction still forwards through the harness path.
log.debug("skipping OpenClaw CLI compaction for Codex runtime session", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
provider: params.provider,
model: params.model,
});
return sessionEntry;
}
const sessionManager = cliCompactionDeps.openSessionManager(sessionFile);
@@ -1372,7 +1372,7 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
});
});
it("falls back to context-engine compaction when native harness binding is recoverable", async () => {
it("does not fall back to context-engine compaction for Codex native binding failures", async () => {
maybeCompactAgentHarnessSessionMock.mockResolvedValueOnce({
ok: false,
compacted: false,
@@ -1389,10 +1389,11 @@ describe("compactEmbeddedPiSession hooks (ownsCompaction engine)", () => {
}),
);
expect(result.ok).toBe(true);
expect(result.compacted).toBe(true);
expect(result.ok).toBe(false);
expect(result.compacted).toBe(false);
expect(result.reason).toBe("no codex app-server thread binding");
expect(maybeCompactAgentHarnessSessionMock).toHaveBeenCalledTimes(1);
expect(contextEngineCompactMock).toHaveBeenCalledTimes(1);
expect(contextEngineCompactMock).not.toHaveBeenCalled();
});
it("does not fire after_compaction when compaction fails", async () => {
@@ -50,7 +50,12 @@ import type { EmbeddedPiCompactResult } from "./types.js";
function shouldFallbackAfterHarnessCompaction(
result: EmbeddedPiCompactResult | undefined,
harnessPolicyRuntime: string | undefined,
explicitHarnessId: string | undefined,
): boolean {
if (harnessPolicyRuntime === "codex" || explicitHarnessId === "codex") {
return false;
}
return (
result?.ok === false &&
(result.failure?.reason === "missing_thread_binding" ||
@@ -126,6 +131,13 @@ export async function compactEmbeddedPiSession(
contextTokenBudget,
contextEnginePluginId: resolveContextEngineOwnerPluginId(contextEngine),
});
const harnessPolicy = resolveAgentHarnessPolicy({
provider: params.provider,
modelId: params.model,
config: params.config,
agentId: agentIds.sessionAgentId,
sessionKey: params.sessionKey,
});
const harnessResult = await maybeCompactAgentHarnessSession({
...params,
contextEngine,
@@ -133,7 +145,13 @@ export async function compactEmbeddedPiSession(
contextEngineRuntimeContext,
});
if (harnessResult) {
if (!shouldFallbackAfterHarnessCompaction(harnessResult)) {
if (
!shouldFallbackAfterHarnessCompaction(
harnessResult,
harnessPolicy.runtime,
params.agentHarnessId,
)
) {
await contextEngine.dispose?.();
return harnessResult;
}
@@ -818,7 +818,7 @@ describe("runMemoryFlushIfNeeded", () => {
expect(compactCall.currentTokenCount).toBeGreaterThanOrEqual(100_000);
});
it("uses the persisted Codex runtime context window for OpenAI preflight compaction", async () => {
it("skips OpenClaw preflight compaction for persisted Codex runtime sessions", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
@@ -835,7 +835,7 @@ describe("runMemoryFlushIfNeeded", () => {
agentHarnessId: "codex",
};
await runPreflightCompactionIfNeeded({
const entry = await runPreflightCompactionIfNeeded({
cfg: {
models: {
providers: {
@@ -860,12 +860,11 @@ describe("runMemoryFlushIfNeeded", () => {
replyOperation: createReplyOperation(),
});
expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1);
const compactCall = requireCompactEmbeddedPiSessionCall();
expect(compactCall.currentTokenCount).toBe(347_000);
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled();
});
it("still compacts when a fresh persisted token total is over the threshold", async () => {
it("leaves fresh over-threshold Codex token snapshots to native Codex auto-compaction", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
@@ -882,7 +881,7 @@ describe("runMemoryFlushIfNeeded", () => {
agentHarnessId: "codex",
};
await runPreflightCompactionIfNeeded({
const entry = await runPreflightCompactionIfNeeded({
cfg: {
models: {
providers: {
@@ -907,9 +906,55 @@ describe("runMemoryFlushIfNeeded", () => {
replyOperation: createReplyOperation(),
});
expect(compactEmbeddedPiSessionMock).toHaveBeenCalledTimes(1);
const compactCall = requireCompactEmbeddedPiSessionCall();
expect(compactCall.currentTokenCount).toBe(347_000);
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled();
});
it("leaves policy-resolved OpenAI Codex sessions to native Codex auto-compaction", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
reserveTokensFloor: 0,
prompt: "Pre-compaction memory flush.\nNO_REPLY",
systemPrompt: "Write memory to memory/YYYY-MM-DD.md.",
relativePath: "memory/2023-11-14.md",
}));
const sessionEntry: SessionEntry = {
sessionId: "session",
updatedAt: Date.now(),
totalTokens: 347_000,
totalTokensFresh: false,
};
const entry = await runPreflightCompactionIfNeeded({
cfg: {
models: {
providers: {
openai: { models: [{ id: "gpt-5.5", contextWindow: 1_000_000 }] },
"openai-codex": { models: [{ id: "gpt-5.5", contextWindow: 350_000 }] },
},
},
agents: { defaults: { compaction: { memoryFlush: {} } } },
} as never,
followupRun: createTestFollowupRun({
provider: "openai",
model: "gpt-5.5",
sessionId: "session",
sessionKey: "agent:main:telegram:default:direct:12345",
runtimePolicySessionKey: "agent:main:telegram:default:direct:12345",
}),
defaultModel: "gpt-5.5",
sessionEntry,
sessionStore: { "agent:main:telegram:default:direct:12345": sessionEntry },
sessionKey: "agent:main:telegram:default:direct:12345",
runtimePolicySessionKey: "agent:main:telegram:default:direct:12345",
storePath: path.join(rootDir, "sessions.json"),
isHeartbeat: false,
replyOperation: createReplyOperation(),
});
expect(entry).toBe(sessionEntry);
expect(compactEmbeddedPiSessionMock).not.toHaveBeenCalled();
});
it("keeps the OpenAI API context window for persisted PI runtime overrides", async () => {
+43 -9
View File
@@ -229,6 +229,19 @@ function resolveFollowupContextConfigProvider(params: {
runtimePolicySessionKey?: string;
}): string {
const provider = params.followupRun.run.provider;
return resolveContextConfigProviderForRuntime({
provider,
runtimeId: resolveFollowupAgentRuntimeId(params),
});
}
function resolveFollowupAgentRuntimeId(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
sessionEntry?: SessionEntry;
sessionKey?: string;
runtimePolicySessionKey?: string;
}): string {
const matchingSessionEntry =
params.sessionEntry?.sessionId === params.followupRun.run.sessionId
? params.sessionEntry
@@ -243,13 +256,10 @@ function resolveFollowupContextConfigProvider(params: {
? persistedRuntimeOverride
: matchingSessionEntry?.agentHarnessId;
if (persistedRuntimeId) {
return resolveContextConfigProviderForRuntime({
provider,
runtimeId: persistedRuntimeId,
});
return persistedRuntimeId;
}
const harnessPolicy = resolveAgentHarnessPolicy({
provider,
provider: params.followupRun.run.provider,
modelId: params.followupRun.run.model,
config: params.cfg,
agentId: params.followupRun.run.agentId,
@@ -259,10 +269,17 @@ function resolveFollowupContextConfigProvider(params: {
params.followupRun.run.runtimePolicySessionKey ??
params.followupRun.run.sessionKey,
});
return resolveContextConfigProviderForRuntime({
provider,
runtimeId: harnessPolicy.runtime,
});
return harnessPolicy.runtime;
}
function followupUsesCodexRuntime(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
sessionEntry?: SessionEntry;
sessionKey?: string;
runtimePolicySessionKey?: string;
}): boolean {
return normalizeLowercaseStringOrEmpty(resolveFollowupAgentRuntimeId(params)) === "codex";
}
function resolveVisibleMemoryFlushErrorPayloads(payloads?: ReplyPayload[]): ReplyPayload[] {
@@ -613,6 +630,23 @@ export async function runPreflightCompactionIfNeeded(params: {
if (params.isHeartbeat || isCli) {
return entry ?? params.sessionEntry;
}
if (
followupUsesCodexRuntime({
cfg: params.cfg,
followupRun: params.followupRun,
sessionEntry: entry,
sessionKey: params.sessionKey,
runtimePolicySessionKey: params.runtimePolicySessionKey,
})
) {
// Codex runtime sessions should reach Codex with their real thread state.
// Its harness owns automatic compaction; OpenClaw preflight compaction is
// only for non-Codex embedded runtimes.
logVerbose(
`preflightCompaction skipped: sessionKey=${params.sessionKey} runtime=codex reason=codex_native_auto_compaction`,
);
return entry ?? params.sessionEntry;
}
const contextWindowTokens = resolveMemoryFlushContextWindowTokens({
cfg: params.cfg,
@@ -347,6 +347,41 @@ describe("handleCompactCommand", () => {
expect(call.tokensAfter).toBe(321);
});
it("reports started Codex native compaction without incrementing completed compaction state", async () => {
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
ok: true,
compacted: false,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: 199_000,
details: {
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
},
},
});
const result = await handleCompactCommand(
{
...buildCompactParams("/compact", {
commands: { text: true },
channels: { whatsapp: { allowFrom: ["*"] } },
} as OpenClawConfig),
sessionEntry: {
sessionId: "live-session",
updatedAt: Date.now(),
},
} as HandleCommandsParams,
true,
);
expect(result?.reply?.text).toContain("Codex compaction started");
expect(vi.mocked(incrementCompactionCount)).not.toHaveBeenCalled();
});
it("resolves /compact context budget from the active Codex runtime config instead of stale session metadata", async () => {
vi.mocked(compactEmbeddedPiSession).mockResolvedValueOnce({
ok: true,
+24 -8
View File
@@ -83,6 +83,19 @@ function formatCompactionReason(reason?: string): string | undefined {
return text;
}
function isCodexNativeCompactionStartedResult(result: { result?: { details?: unknown } }): boolean {
const details = result.result?.details;
if (!details || typeof details !== "object" || Array.isArray(details)) {
return false;
}
const record = details as Record<string, unknown>;
return (
record.backend === "codex-app-server" &&
record.signal === "thread/compact/start" &&
record.pending === true
);
}
function resolveManualCompactContextTokenBudget(params: {
cfg: OpenClawConfig;
provider?: string;
@@ -267,17 +280,20 @@ export const handleCompactCommand: CommandHandler = async (params) => {
ownerNumbers: params.command.ownerList.length > 0 ? params.command.ownerList : undefined,
});
const codexNativeCompactionStarted = isCodexNativeCompactionStartedResult(result);
const compactLabel =
result.ok || isCompactionSkipReason(result.reason)
? result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)}${runtime.formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
? codexNativeCompactionStarted
? "Codex compaction started"
: result.compacted
? result.result?.tokensBefore != null && result.result?.tokensAfter != null
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} ${runtime.formatTokenCount(result.result.tokensAfter)})`
: result.result?.tokensBefore
? `Compacted (${runtime.formatTokenCount(result.result.tokensBefore)} before)`
: "Compacted"
: "Compaction skipped"
: "Compaction failed";
if (result.ok && result.compacted) {
if (result.ok && result.compacted && !codexNativeCompactionStarted) {
await runtime.incrementCompactionCount({
cfg: params.cfg,
sessionEntry: targetSessionEntry,
+23
View File
@@ -1343,6 +1343,29 @@ describe("agentCliCommand", () => {
});
});
it("does not run a fresh embedded session when a /compact control command times out", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
await expect(
agentCliCommand(
{
message: "/compact",
sessionId: "locked-session",
runId: "locked-run",
},
runtime,
),
).rejects.toThrow("gateway timeout");
expect(callGateway).toHaveBeenCalledTimes(1);
expect(agentCommand).not.toHaveBeenCalled();
expect(
mockMessages(runtime.error).some((message) => message.includes("EMBEDDED FALLBACK")),
).toBe(false);
});
});
it("uses the explicit session key agent for timeout fallback sessions", async () => {
await withTempStore(async () => {
callGateway.mockRejectedValue(createGatewayTimeoutError());
+8
View File
@@ -144,6 +144,11 @@ function isGatewayAgentTimeoutError(err: unknown): boolean {
return err instanceof Error && err.message.includes("gateway request timeout for agent");
}
function isControlCommandThatMustNotFallback(opts: Pick<AgentCliOpts, "message">): boolean {
const normalized = opts.message.trim().toLowerCase();
return normalized === "/compact" || normalized.startsWith("/compact ");
}
function isGatewayAgentEmbeddedFallbackError(err: unknown): boolean {
return isGatewayTransportError(err);
}
@@ -730,6 +735,9 @@ export async function agentCliCommand(
throw err;
}
if (isGatewayAgentTimeoutError(err)) {
if (isControlCommandThatMustNotFallback(dispatchOpts)) {
throw err;
}
const fallbackAgentId = resolveAgentIdForGatewayTimeoutFallback(dispatchOpts);
const fallbackSession = createGatewayTimeoutFallbackSession(fallbackAgentId);
runtime.error?.(
@@ -391,6 +391,91 @@ test("sessions.compact without maxLines runs embedded manual compaction for chec
ws.close();
});
test("sessions.compact treats Codex native compaction start as pending, not completed", async () => {
const { dir, storePath } = await createSessionStoreDir();
await fs.writeFile(
path.join(dir, "sess-codex.jsonl"),
`${JSON.stringify({ role: "user", content: "hello codex" })}\n`,
"utf-8",
);
await writeSessionStore({
entries: {
main: sessionStoreEntry("sess-codex", {
agentHarnessId: "codex",
compactionCount: 2,
totalTokens: 54_321,
totalTokensFresh: true,
}),
},
});
embeddedRunMock.compactEmbeddedPiSession.mockResolvedValueOnce({
ok: true,
compacted: false,
result: {
summary: "",
firstKeptEntryId: "",
tokensBefore: 54_321,
details: {
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
},
},
});
const { ws } = await openClient();
await rpcReq(ws, "sessions.subscribe", {});
const endEventPromise = onceMessage(
ws,
(message) =>
message.type === "event" &&
message.event === "session.operation" &&
(message.payload as { operation?: unknown; phase?: unknown })?.operation === "compact" &&
(message.payload as { operation?: unknown; phase?: unknown })?.phase === "end",
);
const compacted = await rpcReq<{
ok: true;
key: string;
compacted: boolean;
result?: { details?: unknown };
}>(ws, "sessions.compact", {
key: "main",
});
expect(compacted.ok).toBe(true);
expect(compacted.payload?.key).toBe("agent:main:main");
expect(compacted.payload?.compacted).toBe(false);
expect(compacted.payload?.result?.details).toMatchObject({
backend: "codex-app-server",
threadId: "thread-1",
signal: "thread/compact/start",
pending: true,
});
const endEvent = await endEventPromise;
expect(endEvent.payload).toMatchObject({
operation: "compact",
phase: "end",
sessionKey: "agent:main:main",
completed: false,
});
const store = JSON.parse(await fs.readFile(storePath, "utf-8")) as Record<
string,
{
compactionCount?: number;
totalTokens?: number;
totalTokensFresh?: boolean;
}
>;
expect(store["agent:main:main"]?.compactionCount).toBe(2);
expect(store["agent:main:main"]?.totalTokens).toBe(54_321);
expect(store["agent:main:main"]?.totalTokensFresh).toBe(true);
ws.close();
});
test("sessions.patch preserves nested model ids under provider overrides", async () => {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-gw-sessions-nested-"));
const storePath = path.join(dir, "sessions.json");