Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions electron/codex-appserver.js
Original file line number Diff line number Diff line change
Expand Up @@ -360,6 +360,7 @@ function runCodexAppServerTurn(opts) {
onError,
onExit,
onServerRequest,
compact = false,
} = opts;

let expectedTurnId = null;
Expand Down Expand Up @@ -560,6 +561,12 @@ function runCodexAppServerTurn(opts) {
session_id: thread.id,
thread,
});
if (compact) {
// Native compaction runs as its own turn; turn/started supplies
// expectedTurnId and turn/completed ends it like any other turn.
await client.send("thread/compact/start", { threadId });
return;
}
const startedTurn = await client.send(
"turn/start",
turnParams(
Expand Down
62 changes: 62 additions & 0 deletions electron/compaction.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
"use strict";

/**
* Provider-native context compaction (#318, #1493 E).
*
* Claude: a bare `/compact` user turn (stdin stream-json, CLI 2.1.283) runs
* the CLI's own compaction and emits `system/compact_boundary` with
* `compact_metadata.{trigger, pre_tokens, post_tokens}`. Auto-compaction
* emits the same event with trigger "auto".
* Codex: app-server `thread/compact/start {threadId}` runs a turn whose only
* item is `contextCompaction` (codex-cli 0.159.2); auto-compaction emits the
* same item inside a normal turn. The next tokenUsage.last is post-compaction.
* Every other provider falls back to fork-to-fresh in the renderer.
*/

const NATIVE_COMPACT_PROVIDERS = new Set(["claude", "codex"]);

/** True when this turn should be sent as the provider's own compaction. */
function isNativeCompactTurn(provider, prompt, sessionId) {
return (
NATIVE_COMPACT_PROVIDERS.has(String(provider || "")) &&
Boolean(sessionId) &&
String(prompt ?? "").trim() === "/compact"
);
}

function formatTokens(n) {
return n >= 1000 ? `${Math.round(n / 100) / 10}k` : String(n);
}

function positive(n) {
const v = Number(n);
return Number.isFinite(v) && v > 0 ? v : null;
}

/**
* Post a "Context compacted" event and reset the ring to the post size.
* Unknown sizes are left out of the text; the ring then waits for the
* provider's next usage report.
* @param {{ store: object, appendMessage: Function }} deps
* @param {string} threadId
* @param {string} runId
* @param {{ pre?: number | null, post?: number | null, auto?: boolean }} info
*/
function recordCompaction(deps, threadId, runId, info) {
const { store, appendMessage } = deps;
const pre = positive(info.pre);
const post = positive(info.post);
const prev = store.getUsage(threadId);
if (post && prev) store.setUsage(threadId, { ...prev, contextTokens: post });
const sizes =
pre && post
? ` (${formatTokens(pre)} → ${formatTokens(post)} tokens)`
: post
? ` (now ${formatTokens(post)} tokens)`
: "";
const text = `${info.auto ? "Context auto-compacted" : "Context compacted"}${sizes}`;
appendMessage(threadId, "event", text, runId);
return text;
}

module.exports = { NATIVE_COMPACT_PROVIDERS, isNativeCompactTurn, recordCompaction };
17 changes: 17 additions & 0 deletions electron/runner-provider-claude.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ const { classifyTool } = require("./guardrails.js");
const { isMemoryConsolidateTool } = require("./memory-consolidate.js");
const { saveToolImages, extractImages } = require("./tool-images.js");
const fs = require("node:fs");
const { recordCompaction } = require("./compaction.js");

/**
* @param {object} ctx - createRunner context (see runner-watchdogs.js header)
Expand Down Expand Up @@ -727,6 +728,22 @@ function createClaudeRun(ctx) {
return;
}

// Native compaction (manual `/compact` or auto). A manual compact's
// result is an empty success, so this also keeps it from reading
// as a phantom result.
if (type === "system" && ev.subtype === "compact_boundary") {
markTurnContent();
const meta = ev.compact_metadata || {};
recordCompaction({ store, appendMessage }, threadId, runId, {
pre: meta.pre_tokens,
post: meta.post_tokens,
auto: meta.trigger === "auto",
});
store.save();
pushDetail(threadId, claudeState);
return;
}

if (type === "assistant" && ev.message && Array.isArray(ev.message.content)) {
// Complete assistant restates streamed partials: replace, don't append.
if (partialText) {
Expand Down
24 changes: 24 additions & 0 deletions electron/runner-provider-codex.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ const path = require("node:path");
const { truncate, INPUT_TRUNCATE, OUTPUT_TRUNCATE } = require("./claude.js");
const codexParse = require("./codex.js");
const { isCodexChildThread } = require("./codex-appserver.js");
const { isNativeCompactTurn, recordCompaction } = require("./compaction.js");
const { guardrailNotice } = require("./guardrail-hook-core.js");

/**
Expand Down Expand Up @@ -115,6 +116,8 @@ function createCodexRun(ctx) {
let terminalError = null;
/** Run-local usage for memory footers (not cumulative store totals). */
const runUsage = { tokensIn: 0, tokensOut: 0, costUsd: 0 };
/** @type {number | undefined} ring size when a compaction item started */
let compactBefore;

const localCwd = thread.worktreePath || project.path;
const binary = resolveBin(providerEntry);
Expand Down Expand Up @@ -383,10 +386,31 @@ function createCodexRun(ctx) {
model: thread.model || null,
reasoningEffort: thread.reasoningEffort || null,
permissionMode: thread.permissionMode || "default",
compact: isNativeCompactTurn("codex", prompt, resumeId),
onServerRequest: (req) => handleCodexServerRequest(threadId, req),
onEvent: (ev) => {
if (!guard()) return;

// Native compaction (thread/compact/start or auto). Usage for the
// compacted context lands between started and completed, so the
// "before" size is read at started.
const compactItem = ev.item && ev.item.type === "contextCompaction";
if (compactItem && ev.type === "item.started") {
compactBefore = (store.getUsage(threadId) || {}).contextTokens;
return;
}
if (compactItem && ev.type === "item.completed") {
const after = (store.getUsage(threadId) || {}).contextTokens;
recordCompaction({ store, appendMessage }, threadId, runId, {
pre: compactBefore,
post: after !== compactBefore ? after : null,
auto: !isNativeCompactTurn("codex", prompt, resumeId),
});
store.save();
pushDetail(threadId, codexState);
return;
}

if (ev.type === "server_request.resolved") {
const live = active.get(threadId);
live.pendingPermissions = live.pendingPermissions.filter((p) => p.rpcId !== ev.requestId);
Expand Down
11 changes: 10 additions & 1 deletion electron/runner.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const { runAgent } = require("./agent.js");
const { whenPathReady } = require("./pathEnv.js");
const { truncate, toolSummary } = require("./claude.js");
const { runCodexAppServerTurn } = require("./codex-appserver.js");
const { isNativeCompactTurn } = require("./compaction.js");
const { heartbeatLane } = require("./mergeQueue.js");
const { createWatchdogs } = require("./runner-watchdogs.js");
const { createAskRuns } = require("./runner-ask-runs.js");
Expand Down Expand Up @@ -1938,7 +1939,15 @@ function createRunner(opts) {
});
if (abortIfCancelled(threadId, runId)) return { runId };

const dispatchPrompt = promptPrefix + bootNote;
// Native `/compact` goes out bare: Claude reads any trailing text as
// summary instructions, and Codex swaps the turn for thread/compact/start.
const dispatchPrompt = isNativeCompactTurn(
provider,
rawPrompt,
dispatchThread.sessionId,
)
? "/compact"
: promptPrefix + bootNote;

const name = workflowNameFromThreadId(threadId);

Expand Down
172 changes: 172 additions & 0 deletions electron/test/compaction.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
const { describe, it, beforeEach, afterEach } = require("node:test");
const assert = require("node:assert/strict");
const fs = require("node:fs");
const os = require("node:os");
const path = require("node:path");
const { pathToFileURL } = require("node:url");
const { execFileSync } = require("node:child_process");
const { Store } = require("../store.js");
const services = require("../services.js");
const { createRunner } = require("../runner.js");
const { isNativeCompactTurn } = require("../compaction.js");
const { writeFakeBin } = require("./support/fakeBin.js");
const { writeFakeCodexBin } = require("./support/fakeCodexCli.js");
const { rmTree } = require("./support/rmTree.js");

const CLAUDE_FIXTURE = path.join(__dirname, "fixtures/claude-compact.jsonl");

async function loadCore() {
const corePath = path.join(__dirname, "../../core/dist/index.js");
return import(pathToFileURL(corePath).href);
}

function waitFor(predicate, { timeoutMs = 15000, intervalMs = 20 } = {}) {
return new Promise((resolve, reject) => {
const start = Date.now();
const tick = () => {
if (predicate()) return resolve();
if (Date.now() - start > timeoutMs) return reject(new Error("waitFor timed out"));
setTimeout(tick, intervalMs);
};
tick();
});
}

describe("isNativeCompactTurn", () => {
it("only claude and codex, only with a session, only a bare /compact", () => {
assert.equal(isNativeCompactTurn("claude", " /compact\n", "s"), true);
assert.equal(isNativeCompactTurn("codex", "/compact", "s"), true);
assert.equal(isNativeCompactTurn("grok", "/compact", "s"), false);
assert.equal(isNativeCompactTurn("claude", "/compact", null), false);
assert.equal(isNativeCompactTurn("claude", "/compact keep tests", "s"), false);
});
});

describe("native compaction runs (#318)", () => {
const ENV = [
"CODER_SIMULATE",
"CODER_AGENT_CMD",
"CODER_CLAUDE_BIN",
"CODER_CODEX_BIN",
"CODER_GROK_MCP_DISABLE",
];
let saved;
let tmpDir;
let store;
let runner;
let thread;

beforeEach(async () => {
saved = Object.fromEntries(ENV.map((k) => [k, process.env[k]]));
delete process.env.CODER_SIMULATE;
delete process.env.CODER_AGENT_CMD;
process.env.CODER_GROK_MCP_DISABLE = "1";
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "coder-compact-"));
store = new Store(path.join(tmpDir, "store.json"));
runner = createRunner({ store, core: await loadCore(), pushFn: () => {}, tickMs: 15 });
const repo = path.join(tmpDir, "app");
fs.mkdirSync(repo);
for (const args of [
["init"],
["config", "user.email", "t@t.com"],
["config", "user.name", "t"],
["commit", "--allow-empty", "-m", "init"],
]) {
execFileSync("git", args, { cwd: repo, stdio: "ignore" });
}
await services.addProject(store, repo);
thread = services.createThread(store, {
projectId: store.getProjects()[0].id,
title: "Compact me",
});
});

afterEach(async () => {
if (runner) runner.stopAll();
await rmTree(tmpDir);
for (const [k, v] of Object.entries(saved)) {
if (v === undefined) delete process.env[k];
else process.env[k] = v;
}
});

function seed(sessionId, contextTokens) {
store.updateThread(thread.id, { sessionId });
store.setUsage(thread.id, {
model: null,
inputTokens: 1,
outputTokens: 1,
costUsd: 0,
turns: 1,
contextTokens,
});
}

const events = () =>
store
.getMessages(thread.id)
.filter((m) => m.role === "event")
.map((m) => m.text);

it("claude: sends a bare /compact and resets the ring from compact_boundary", async () => {
const stdinFile = path.join(tmpDir, "stdin.txt");
process.env.CODER_CLAUDE_BIN = writeFakeBin(
path.join(tmpDir, "fake-claude.js"),
`#!/usr/bin/env node
"use strict";
const fs = require("node:fs");
require("node:readline").createInterface({ input: process.stdin }).once("line", (line) => {
fs.writeFileSync(${JSON.stringify(stdinFile)}, line);
process.stdout.write(fs.readFileSync(${JSON.stringify(CLAUDE_FIXTURE)}, "utf8"));
setTimeout(() => process.exit(0), 50);
});
`,
);
seed("74038ac0-c4c1-462a-aba4-583498cd3ccf", 27495);
await runner.startRun({ threadId: thread.id, prompt: "/compact" });
await waitFor(() => store.getThread(thread.id).status !== "working");

const sent = JSON.parse(fs.readFileSync(stdinFile, "utf8"));
assert.equal(sent.message.content, "/compact");
// Empty success result must not be held as a phantom and failed.
assert.equal(store.getThread(thread.id).status, "done");
assert.ok(events().includes("Context compacted (27.5k → 5.6k tokens)"), events().join("\n"));
assert.equal(store.getUsage(thread.id).contextTokens, 5562);
});

it("claude: auto-compaction mid-turn posts an event and the result's usage wins", async () => {
process.env.CODER_CLAUDE_BIN = writeFakeBin(
path.join(tmpDir, "fake-claude-auto.js"),
`#!/usr/bin/env node
"use strict";
const emit = (o) => process.stdout.write(JSON.stringify(o) + "\\n");
emit({ type: "system", subtype: "init", session_id: "s-auto", model: "m" });
emit({ type: "system", subtype: "compact_boundary", session_id: "s-auto",
compact_metadata: { trigger: "auto", pre_tokens: 190000, post_tokens: 12000 } });
emit({ type: "assistant", message: { content: [{ type: "text", text: "done" }] } });
emit({ type: "result", subtype: "success", result: "done", session_id: "s-auto",
usage: { input_tokens: 2, cache_read_input_tokens: 12000, cache_creation_input_tokens: 500, output_tokens: 98 } });
setTimeout(() => process.exit(0), 50);
`,
);
seed("s-auto", 190000);
await runner.startRun({ threadId: thread.id, prompt: "keep going" });
await waitFor(() => store.getThread(thread.id).status !== "working");

assert.equal(store.getThread(thread.id).status, "done");
assert.ok(events().includes("Context auto-compacted (190k → 12k tokens)"), events().join("\n"));
assert.equal(store.getUsage(thread.id).contextTokens, 2 + 12000 + 500 + 98);
});

it("codex: runs thread/compact/start and resets the ring from the next usage", async () => {
process.env.CODER_CODEX_BIN = writeFakeCodexBin(tmpDir, writeFakeBin);
services.setProvider(store, { threadId: thread.id, provider: "codex" });
seed("codex-sess-001", 19440);
await runner.startRun({ threadId: thread.id, prompt: "/compact" });
await waitFor(() => store.getThread(thread.id).status !== "working");

assert.equal(store.getThread(thread.id).status, "done");
assert.ok(events().includes("Context compacted (19.4k → 5.6k tokens)"), events().join("\n"));
assert.equal(store.getUsage(thread.id).contextTokens, 5579);
});
});
6 changes: 6 additions & 0 deletions electron/test/fixtures/claude-compact.jsonl
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
{"type": "system", "subtype": "status", "status": "compacting", "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "uuid": "58052bc0-b33b-49de-86cb-9aa0c8362e01"}
{"type": "system", "subtype": "status", "status": null, "compact_result": "success", "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "uuid": "147e8083-f0e5-48a3-8af4-0f28a5515997"}
{"type": "system", "subtype": "compact_boundary", "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "uuid": "04818d0e-2993-49f3-ab7f-c96f063e5af6", "compact_metadata": {"trigger": "manual", "pre_tokens": 27495, "post_tokens": 5562, "cumulative_dropped_tokens": 21933, "duration_ms": 11649}, "logical_parent_uuid": "7c06f80e-a17c-4471-9839-b531199e2f7d"}
{"type": "user", "message": {"role": "user", "content": "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nSummary:\n1. Primary Request and Intent:\n The \u2026"}, "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "parent_tool_use_id": null, "uuid": "af793fd5-c8e9-4b36-ad76-16bfb6e53fc9", "timestamp": "2026-10-06T13:12:22.268Z", "isReplay": false, "isSynthetic": true}
{"type": "user", "message": {"role": "user", "content": "<local-command-stdout>Compacted </local-command-stdout>"}, "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "parent_tool_use_id": null, "uuid": "fb27475a-a9b6-4661-954e-57d917ad9df9", "timestamp": "2026-10-06T13:12:22.416Z", "isReplay": true}
{"is_error": false, "duration_api_ms": 0, "num_turns": 0, "stop_reason": null, "session_id": "74038ac0-c4c1-462a-aba4-583498cd3ccf", "total_cost_usd": 0.039152450000000005, "usage": {"output_tokens_details": {"thinking_tokens": 0}, "input_tokens": 0, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0, "output_tokens": 0, "server_tool_use": {"web_search_requests": 0, "web_fetch_requests": 0}, "service_tier": "standard", "cache_creation": {"ephemeral_1h_input_tokens": 0, "ephemeral_5m_input_tokens": 0}, "inference_geo": "", "iterations": [], "speed": "standard"}, "modelUsage": {"claude-haiku-4-5-20251001": {"inputTokens": 1446, "outputTokens": 1255, "thinkingTokens": 606, "cacheReadInputTokens": 40877, "cacheCreationInputTokens": 13784, "webSearchRequests": 0, "costUSD": 0.039152450000000005, "contextWindow": 200000, "maxOutputTokens": 32000, "canonicalModel": "claude-haiku-4-5", "provider": "firstParty", "costBasis": "list"}}, "permission_denials": [], "fast_mode_state": "off", "fast_mode_disabled_reason": "sdk_opt_in_required", "subagent_stats": {"spawned": 0, "requested": {"background": 0, "foreground": 0, "unset": 0}, "started_in_background": 0, "max_depth": 0, "spawned_by_subagents": 0, "completed": 0, "failed": 0, "killed": {"parent": 0, "user": 0, "system": 0}, "refused": {"depth_limit": 0, "concurrency_limit": 0, "budget": 0}, "by_type": {}}, "subtype": "success", "result": "", "local_command": "compact", "type": "result", "duration_ms": 11748, "uuid": "21f3bddd-25ec-49b6-98f2-7c6247e3683e", "queued_turn_count": 0, "result_index": 0}
Loading
Loading