Add Codex context compaction support (#990)

This commit is contained in:
Mohamed Boudra
2026-05-13 21:52:21 +08:00
committed by GitHub
parent e2ecae0e72
commit c3012ea520
6 changed files with 413 additions and 8 deletions

View File

@@ -540,7 +540,13 @@ const AgentStreamViewComponent = forwardRef<AgentStreamViewHandle, AgentStreamVi
return <TodoListCard items={item.items} />;
case "compaction":
return <CompactionMarker status={item.status} preTokens={item.preTokens} />;
return (
<CompactionMarker
status={item.status}
trigger={item.trigger}
preTokens={item.preTokens}
/>
);
default:
return null;

View File

@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import { getCompactionMarkerLabel } from "./message-compaction-label";
describe("getCompactionMarkerLabel", () => {
it("renders loading, automatic, manual, tokenized, and fallback labels", () => {
expect(getCompactionMarkerLabel({ status: "loading" })).toBe("Compacting...");
expect(getCompactionMarkerLabel({ status: "completed", trigger: "auto" })).toBe(
"Context automatically compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", trigger: "manual" })).toBe(
"Context manually compacted",
);
expect(getCompactionMarkerLabel({ status: "completed", preTokens: 12_345 })).toBe(
"Context compacted (12K tokens)",
);
expect(getCompactionMarkerLabel({ status: "completed" })).toBe("Context compacted");
});
});

View File

@@ -0,0 +1,17 @@
export interface CompactionMarkerLabelInput {
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
export function getCompactionMarkerLabel({
status,
trigger,
preTokens,
}: CompactionMarkerLabelInput): string {
if (status === "loading") return "Compacting...";
if (trigger === "auto") return "Context automatically compacted";
if (trigger === "manual") return "Context manually compacted";
if (preTokens) return `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
return "Context compacted";
}

View File

@@ -92,6 +92,7 @@ import {
import { PlanCard } from "./plan-card";
import { useToolCallSheet } from "./tool-call-sheet";
import { ToolCallDetailsContent } from "./tool-call-details";
import { getCompactionMarkerLabel } from "./message-compaction-label";
import { useAttachmentPreviewUrl } from "@/attachments/use-attachment-preview-url";
import { persistAttachmentFromBytes, persistAttachmentFromDataUrl } from "@/attachments/service";
import type { DaemonClient } from "@server/client/daemon-client";
@@ -1978,6 +1979,7 @@ export const ActivityLog = memo(function ActivityLog({
interface CompactionMarkerProps {
status: "loading" | "completed";
trigger?: "auto" | "manual";
preTokens?: number;
}
@@ -2008,12 +2010,10 @@ const compactionStylesheet = StyleSheet.create((theme) => ({
export const CompactionMarker = memo(function CompactionMarker({
status,
trigger,
preTokens,
}: CompactionMarkerProps) {
let label: string;
if (status === "loading") label = "Compacting...";
else if (preTokens) label = `Context compacted (${Math.round(preTokens / 1000)}K tokens)`;
else label = "Context compacted";
const label = getCompactionMarkerLabel({ status, trigger, preTokens });
return (
<View style={compactionStylesheet.container}>

View File

@@ -1290,6 +1290,10 @@ describe("Codex app-server provider", () => {
id: "message-history",
text: "History loaded.",
},
{
type: "contextCompaction",
id: "compact-history",
},
],
},
],
@@ -1318,6 +1322,14 @@ describe("Codex app-server provider", () => {
messageId: "message-history",
},
},
{
type: "timeline",
provider: "codex",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
@@ -1380,6 +1392,116 @@ describe("Codex app-server provider", () => {
]);
});
test("emits Codex context compaction markers from live thread items", () => {
const session = createSession();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "compact-live",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "compact-live",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "loading",
},
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("emits and dedupes Codex thread/compacted notifications", () => {
const session = createSession();
session.activeForegroundTurnId = null;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn",
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "legacy-compact-item",
},
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("emits consecutive Codex thread/compacted notifications", () => {
const session = createSession();
session.activeForegroundTurnId = null;
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn-1",
});
asInternals(session).handleNotification("thread/compacted", {
threadId: "test-thread",
turnId: "legacy-compact-turn-2",
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn-1",
item: {
type: "compaction",
status: "completed",
},
},
{
type: "timeline",
provider: "codex",
turnId: "legacy-compact-turn-2",
item: {
type: "compaction",
status: "completed",
},
},
]);
});
test("does not replace a persisted Codex thread when app-server resume fails", async () => {
const session = createSession({ thinkingOptionId: "medium" });
session.currentThreadId = "archived-thread-id";
@@ -1450,6 +1572,77 @@ describe("Codex app-server provider", () => {
]);
});
test("lists /compact and sends Codex compaction out of band", async () => {
const requests: Array<{ method: string; params: unknown }> = [];
const session = createSession();
session.client = {
request: vi.fn(async (method: string, params: unknown) => {
requests.push({ method, params });
if (method === "thread/loaded/list") {
return { data: ["test-thread"] };
}
if (method === "skills/list") {
return { data: [] };
}
return {};
}),
};
await expect(session.listCommands?.()).resolves.toContainEqual({
name: "compact",
description: "Summarize conversation to prevent hitting the context limit",
argumentHint: "",
});
const handler = session.tryHandleOutOfBand?.("/compact");
expect(handler).not.toBeNull();
const events: AgentStreamEvent[] = [];
session.subscribe((event) => events.push(event));
await handler?.run({ emit: (event) => events.push(event) });
asInternals(session).handleNotification("item/started", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "manual-compact",
},
});
asInternals(session).handleNotification("item/completed", {
threadId: "test-thread",
item: {
type: "contextCompaction",
id: "manual-compact",
},
});
expect(requests).toContainEqual({
method: "thread/compact/start",
params: { threadId: "test-thread" },
});
expect(events).toEqual([
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "loading",
trigger: "manual",
},
},
{
type: "timeline",
provider: "codex",
turnId: "test-turn",
item: {
type: "compaction",
status: "completed",
trigger: "manual",
},
},
]);
});
test("maps question responses from headers back to question ids and completes the tool call", async () => {
const session = createSession();
const events: AgentStreamEvent[] = [];

View File

@@ -98,6 +98,7 @@ const CODEX_TOOL_THREAD_ITEM_TYPES = new Set([
"webSearch",
"collabAgentToolCall",
]);
const CODEX_CONTEXT_COMPACTION_TYPE = "contextCompaction";
const CODEX_PLAN_IMPLEMENTATION_PROMPT_PREFIX =
"The user approved the plan. Implement it now. Do not restate or revise the plan unless blocked.";
@@ -1546,6 +1547,11 @@ function threadItemToTimeline(
return mapCodexThreadPlanItem(normalizedItem);
case "reasoning":
return mapCodexThreadReasoningItem(normalizedItem);
case CODEX_CONTEXT_COMPACTION_TYPE:
return {
type: "compaction",
status: "completed",
};
default:
return null;
}
@@ -1729,6 +1735,13 @@ const ItemLifecycleNotificationSchema = z
})
.passthrough();
const ContextCompactedNotificationSchema = z
.object({
threadId: z.string(),
turnId: z.string().optional(),
})
.passthrough();
const CodexEventTurnAbortedNotificationSchema = z
.object({
msg: z
@@ -1958,6 +1971,7 @@ type ParsedCodexNotification =
itemId: string;
delta: string | null;
}
| { kind: "context_compacted"; threadId: string; turnId: string | null }
| { kind: "invalid_payload"; method: string; params: unknown }
| { kind: "unknown_method"; method: string; params: unknown };
@@ -2056,6 +2070,22 @@ const CodexNotificationSchema = z.union([
params,
}),
),
z
.object({ method: z.literal("thread/compacted"), params: ContextCompactedNotificationSchema })
.transform(
({ params }): ParsedCodexNotification => ({
kind: "context_compacted",
threadId: params.threadId,
turnId: params.turnId ?? null,
}),
),
z.object({ method: z.literal("thread/compacted"), params: z.unknown() }).transform(
({ method, params }): ParsedCodexNotification => ({
kind: "invalid_payload",
method,
params,
}),
),
z
.object({
method: z.literal("item/agentMessage/delta"),
@@ -2679,6 +2709,12 @@ class CodexAppServerAgentSession implements AgentSession {
private warnedIncompleteEditToolCallIds = new Set<string>();
private latestUsage: AgentUsage | undefined;
private latestPlanResult: { callId: string; text: string; turnId: string | null } | null = null;
private pendingManualCompactionStarts = 0;
private compactionTriggerByItemId = new Map<string, "auto" | "manual">();
// Codex can report one completed compaction through both channels:
// `thread/compacted` and a completed `contextCompaction` item.
private unpairedCompactionNotificationCompletions = 0;
private unpairedCompactionItemCompletions = 0;
private connected = false;
private collaborationModes: Array<{
name: string;
@@ -3610,7 +3646,13 @@ class CodexAppServerAgentSession implements AgentSession {
appServerSkills.length === 0
? await listCodexSkills(this.config.cwd, this.deps.workspaceGitService)
: [];
const builtin: AgentSlashCommand[] = [];
const builtin: AgentSlashCommand[] = [
{
name: "compact",
description: "Summarize conversation to prevent hitting the context limit",
argumentHint: "",
},
];
if (this.goalsEnabled) {
builtin.push({
name: "goal",
@@ -3626,10 +3668,26 @@ class CodexAppServerAgentSession implements AgentSession {
tryHandleOutOfBand(
prompt: AgentPromptInput,
): { run(ctx: { emit: (event: AgentStreamEvent) => void }): Promise<void> } | null {
if (!this.goalsEnabled) return null;
if (typeof prompt !== "string") return null;
const parsed = this.parseSlashCommandInput(prompt);
if (!parsed || parsed.commandName !== "goal") return null;
if (!parsed) return null;
if (parsed.commandName === "compact") {
return {
run: async ({ emit }) => {
const error = await this.executeCompactCommand();
if (error) {
emit({
type: "timeline",
provider: CODEX_PROVIDER,
item: { type: "assistant_message", text: formatOutOfBandStatusMessage(error) },
});
}
},
};
}
if (!this.goalsEnabled || parsed.commandName !== "goal") return null;
const subcommand = parseGoalSubcommand(parsed.args);
return {
@@ -3644,6 +3702,33 @@ class CodexAppServerAgentSession implements AgentSession {
};
}
private async executeCompactCommand(): Promise<string | null> {
try {
await this.connect();
if (this.currentThreadId) {
await this.ensureThreadLoaded();
} else {
await this.ensureThread();
}
if (!this.client || !this.currentThreadId) {
throw new Error("Codex thread is not available");
}
this.pendingManualCompactionStarts += 1;
try {
await this.client.request("thread/compact/start", {
threadId: this.currentThreadId,
});
} catch (error) {
this.pendingManualCompactionStarts = Math.max(0, this.pendingManualCompactionStarts - 1);
throw error;
}
return null;
} catch (error) {
const message = error instanceof Error ? error.message : "unknown error";
return `Failed to compact context: ${message}`;
}
}
private async executeGoalSubcommand(subcommand: GoalSubcommand): Promise<string> {
if (subcommand.kind === "usage") {
return "Usage: /goal <objective>|pause|resume|clear";
@@ -3875,6 +3960,9 @@ class CodexAppServerAgentSession implements AgentSession {
case "token_usage_updated":
this.handleTokenUsageUpdatedNotification(parsed);
return;
case "context_compacted":
this.handleContextCompactedNotification(parsed);
return;
case "agent_message_delta":
case "reasoning_delta":
case "exec_command_output_delta":
@@ -4201,6 +4289,8 @@ class CodexAppServerAgentSession implements AgentSession {
this.pendingFileChangeOutputDeltas.clear();
this.pendingAssistantMessageBoundary = false;
this.warnedIncompleteEditToolCallIds.clear();
this.unpairedCompactionNotificationCompletions = 0;
this.unpairedCompactionItemCompletions = 0;
}
private handlePlanUpdatedNotification(
@@ -4240,6 +4330,65 @@ class CodexAppServerAgentSession implements AgentSession {
}
}
private resolveContextCompactionTrigger(itemId?: string): "auto" | "manual" | undefined {
if (itemId) {
const known = this.compactionTriggerByItemId.get(itemId);
if (known) {
return known;
}
}
if (this.pendingManualCompactionStarts > 0) {
this.pendingManualCompactionStarts -= 1;
return "manual";
}
return undefined;
}
private createContextCompactionTimelineItem(
status: "loading" | "completed",
itemId?: string,
): Extract<AgentTimelineItem, { type: "compaction" }> {
const trigger = this.resolveContextCompactionTrigger(itemId);
if (itemId && trigger) {
if (status === "loading") {
this.compactionTriggerByItemId.set(itemId, trigger);
} else {
this.compactionTriggerByItemId.delete(itemId);
}
}
return {
type: "compaction",
status,
...(trigger ? { trigger } : {}),
};
}
private isContextCompactionItem(item: { type?: string; [key: string]: unknown }): boolean {
return (
normalizeCodexThreadItemType(typeof item.type === "string" ? item.type : undefined) ===
CODEX_CONTEXT_COMPACTION_TYPE
);
}
private handleContextCompactedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "context_compacted" }>,
): void {
if (parsed.threadId !== this.currentThreadId) {
return;
}
if (this.unpairedCompactionItemCompletions > 0) {
this.unpairedCompactionItemCompletions -= 1;
return;
}
this.unpairedCompactionNotificationCompletions += 1;
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed"),
...(parsed.turnId ? { turnId: parsed.turnId } : {}),
});
}
private handleExecCommandStartedNotification(
parsed: Extract<ParsedCodexNotification, { kind: "exec_command_started" }>,
): void {
@@ -4357,6 +4506,19 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.source === "codex_event") {
return;
}
if (this.isContextCompactionItem(parsed.item)) {
if (this.unpairedCompactionNotificationCompletions > 0) {
this.unpairedCompactionNotificationCompletions -= 1;
return;
}
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("completed", parsed.item.id),
});
this.unpairedCompactionItemCompletions += 1;
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,
@@ -4487,6 +4649,14 @@ class CodexAppServerAgentSession implements AgentSession {
if (parsed.source === "codex_event") {
return;
}
if (this.isContextCompactionItem(parsed.item)) {
this.emitEvent({
type: "timeline",
provider: CODEX_PROVIDER,
item: this.createContextCompactionTimelineItem("loading", parsed.item.id),
});
return;
}
const timelineItem = threadItemToTimeline(parsed.item, {
includeUserMessage: false,
cwd: this.config.cwd ?? null,