fix(codex): announce automatic context compaction when it starts (#1999)

This commit is contained in:
Junmo Kim
2026-10-13 08:20:02 +08:00
committed by GitHub
parent b3a9c0cddf
commit 5847b034e5
6 changed files with 398 additions and 11 deletions
+41
View File
@@ -106,4 +106,45 @@ describe('shared history projection', () => {
await projection.notification('item/completed', { threadId: 'child', turnId: 'turn', item: { id: 'prompt', type: 'userMessage', content: [{ type: 'text', text: 'child prompt' }], clientId: 'cid' } });
expect(user).not.toHaveBeenCalled(); expect(committed).not.toHaveBeenCalled();
});
it('announces parent compaction start via session event, deduping repeats', async () => {
const send = vi.fn(); const sendEvent = vi.fn();
const session = { getMetadata: () => ({}), sendAgentMessage: send, sendSessionEvent: sendEvent } as unknown as ApiSessionClient;
const projection = new SharedCodexProjection(session, 'thread', async () => {});
const params = { threadId: 'thread', turnId: 'turn', item: { id: 'c1', type: 'contextCompaction' } };
await projection.notification('item/started', params);
await projection.notification('item/started', params);
await projection.notification('item/started', { threadId: 'thread', turnId: 'turn', item: { id: 'c2', type: 'contextCompaction' } });
expect(send).not.toHaveBeenCalled();
expect(sendEvent.mock.calls).toEqual([
[{ type: 'message', message: '📦 Compaction started' }],
[{ type: 'message', message: '📦 Compaction started' }]
]);
});
it('keeps child compaction start as trace with statusText, never a parent status', async () => {
const send = vi.fn(); const sendEvent = vi.fn();
const session = { getMetadata: () => ({}), sendAgentMessage: send, sendSessionEvent: sendEvent } as unknown as ApiSessionClient;
const projection = new SharedCodexProjection(session, 'child', async () => {}, 'root');
await projection.notification('item/started', { threadId: 'child', turnId: 'turn', item: { id: 'c1', type: 'contextCompaction' } });
expect(sendEvent).not.toHaveBeenCalled();
expect(send).toHaveBeenCalledTimes(1);
const [body] = send.mock.calls[0];
expect(body.type).toBe('agent-run-trace');
expect(body.message.type).toBe('compaction_started');
expect(body.message.statusText).toBe('📦 Compaction started');
});
it('emits live compaction start but not for completed history replay', async () => {
const send = vi.fn(); const sendEvent = vi.fn();
const session = { getMetadata: () => ({}), sendAgentMessage: send, sendSessionEvent: sendEvent } as unknown as ApiSessionClient;
const projection = new SharedCodexProjection(session, 'thread', async () => {});
await projection.history({ turns: [{ id: 'turn', status: 'completed',
items: [{ id: 'c1', type: 'contextCompaction' }] }] });
expect(sendEvent).not.toHaveBeenCalled();
expect(send.mock.calls.map(([b]) => b).some((b) => b.message === '📦 Compaction started')).toBe(false);
expect(send.mock.calls.map(([b]) => b)).toContainEqual(expect.objectContaining({ type: 'context_compacted' }));
send.mockClear();
await projection.notification('item/started', { threadId: 'thread', turnId: 'live', item: { id: 'c9', type: 'contextCompaction' } });
expect(send).not.toHaveBeenCalled();
expect(sendEvent).toHaveBeenCalledWith({ type: 'message', message: '📦 Compaction started' });
});
});
+17 -3
View File
@@ -90,7 +90,7 @@ export class SharedCodexProjection {
await this.project(method, params, modelAtReceipt);
}
private async project(method: string, params: unknown, modelAtReceipt?: string): Promise<void> {
private async project(method: string, params: unknown, modelAtReceipt?: string, fromHistory = false): Promise<void> {
if (method.startsWith('codex/event/')) return;
const p = record(params);
const item = record(p.item);
@@ -146,6 +146,20 @@ export class SharedCodexProjection {
this.send({ type: 'tool-call', name: event.name, callId, input: event.input ?? event.arguments }, key);
} else if (event.type === 'codex_tool_call_end' && callId) {
this.send({ type: 'tool-call-result', callId, output: event.output, is_error: event.is_error }, key);
} else if (event.type === 'compaction_started') {
// History replay re-projects every completed item's start;
// a finished compaction must not announce a live status.
if (fromHistory) continue;
if (this.parentThreadId) {
this.send({ type: 'compaction_started', statusText: '📦 Compaction started' }, key);
} else {
// Status UI listens on session events; an agent message
// would render as a normal assistant bubble instead.
// Dedup is in-memory only (this.emitted set).
if (this.emitted.has(key)) continue;
this.emitted.add(key);
this.session.sendSessionEvent({ type: 'message', message: '📦 Compaction started' });
}
} else if (event.type === 'token_count' || event.type === 'context_compacted' || event.type.startsWith('thread_goal_')) {
const model = event.type === 'token_count' && turnId ? this.turnModels.get(turnId) : undefined;
this.send({ ...event, ...(model ? { model } : {}), flavor: 'codex', scope: { role: 'parent', threadId: this.threadId }, scope_role: 'parent', thread_id: this.threadId }, key);
@@ -191,7 +205,7 @@ export class SharedCodexProjection {
if (!this.parentThreadId && string(record(item).id) && pendingTitle && !this.completedTitles.has(titleKey)) {
this.pendingTitles.set(titleKey, pendingTitle);
}
await this.project('item/started', params);
await this.project('item/started', params, undefined, true);
// Active snapshots can contain partial assistant text. Do not
// settle it under the final stable id and suppress completion.
if (turn.status !== 'inProgress' || record(item).status === 'completed' || record(item).type === 'userMessage') {
@@ -203,7 +217,7 @@ export class SharedCodexProjection {
}
this.pendingTitles.delete(titleKey);
}
await this.project('item/completed', params);
await this.project('item/completed', params, undefined, true);
}
}
}