Add Codex clear and compact slash commands (#541)

* Add Codex clear and compact slash commands

* Stabilize queued thinking event test

* Interrupt active Codex turn before slash commands
This commit is contained in:
NightWatcher314
2026-04-29 14:15:44 +08:00
committed by GitHub
parent 0160da4bb5
commit a612be50d6
15 changed files with 460 additions and 16 deletions
+127 -1
View File
@@ -9,6 +9,9 @@ const harness = vi.hoisted(() => ({
startThreadIds: [] as string[],
resumeThreadIds: [] as string[],
startTurnThreadIds: [] as string[],
interruptedTurns: [] as Array<{ threadId: string; turnId: string }>,
compactThreadIds: [] as string[],
suppressTurnCompletion: false,
remainingThreadSystemErrors: 0
}));
@@ -62,6 +65,10 @@ vi.mock('./codexAppServerClient', () => {
return { turn: { id: turnId } };
}
if (harness.suppressTurnCompletion) {
return { turn: { id: turnId } };
}
const completed = { status: 'Completed', turn: { id: turnId } };
harness.notifications.push({ method: 'turn/completed', params: completed });
this.notificationHandler?.('turn/completed', completed);
@@ -69,7 +76,16 @@ vi.mock('./codexAppServerClient', () => {
return { turn: { id: turnId } };
}
async interruptTurn(): Promise<Record<string, never>> {
async interruptTurn(params?: { threadId?: string; turnId?: string }): Promise<Record<string, never>> {
harness.interruptedTurns.push({
threadId: params?.threadId ?? 'thread-unknown',
turnId: params?.turnId ?? 'turn-unknown'
});
return {};
}
async compactThread(params?: { threadId?: string }): Promise<Record<string, never>> {
harness.compactThreadIds.push(params?.threadId ?? 'thread-unknown');
return {};
}
@@ -117,6 +133,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
const codexMessages: unknown[] = [];
const thinkingChanges: boolean[] = [];
const foundSessionIds: string[] = [];
const resetThreadCalls: string[] = [];
let currentModel: string | null | undefined;
let agentState: FakeAgentState = {
requests: {},
@@ -168,6 +185,10 @@ function createSessionStub(messages = ['hello from launcher test']) {
session.sessionId = id;
foundSessionIds.push(id);
},
resetCodexThread() {
resetThreadCalls.push(session.sessionId ?? 'none');
session.sessionId = null;
},
sendAgentMessage(message: unknown) {
client.sendAgentMessage(message);
},
@@ -185,6 +206,7 @@ function createSessionStub(messages = ['hello from launcher test']) {
codexMessages,
thinkingChanges,
foundSessionIds,
resetThreadCalls,
rpcHandlers,
getModel: () => currentModel,
getAgentState: () => agentState
@@ -199,6 +221,9 @@ describe('codexRemoteLauncher', () => {
harness.startThreadIds = [];
harness.resumeThreadIds = [];
harness.startTurnThreadIds = [];
harness.interruptedTurns = [];
harness.compactThreadIds = [];
harness.suppressTurnCompletion = false;
harness.remainingThreadSystemErrors = 0;
});
@@ -260,4 +285,105 @@ describe('codexRemoteLauncher', () => {
expect(session.sessionId).toBe('thread-2');
expect(session.thinking).toBe(false);
});
it('clears codex thread state without starting a turn', async () => {
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(resetThreadCalls).toEqual(['none']);
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Context was reset'
});
expect(session.sessionId).toBe('thread-1');
});
it('interrupts an in-flight turn before clearing codex thread state', async () => {
harness.suppressTurnCompletion = true;
const { session, sessionEvents, resetThreadCalls } = createSessionStub(['first message', '/clear']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(resetThreadCalls).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Context was reset'
});
expect(session.thinking).toBe(false);
});
it('compacts the current thread without starting a turn', async () => {
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.compactThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction started'
});
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction completed'
});
});
it('interrupts an in-flight turn before compacting the current thread', async () => {
harness.suppressTurnCompletion = true;
const { session, sessionEvents } = createSessionStub(['first message', '/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual(['thread-1']);
expect(harness.startTurnThreadIds).toEqual(['thread-1']);
expect(harness.interruptedTurns).toEqual([{ threadId: 'thread-1', turnId: 'turn-1' }]);
expect(harness.compactThreadIds).toEqual(['thread-1']);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Compaction completed'
});
expect(session.thinking).toBe(false);
});
it('reports nothing to compact when no codex thread exists', async () => {
const { session, sessionEvents } = createSessionStub(['/compact']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual([]);
expect(harness.startTurnThreadIds).toEqual([]);
expect(harness.compactThreadIds).toEqual([]);
expect(sessionEvents).toContainEqual({
type: 'message',
message: 'Nothing to compact'
});
});
it('rejects argument-bearing codex slash commands without starting a turn', async () => {
const { session, sessionEvents } = createSessionStub(['/compact now']);
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(harness.startThreadIds).toEqual([]);
expect(harness.startTurnThreadIds).toEqual([]);
expect(harness.compactThreadIds).toEqual([]);
expect(sessionEvents).toContainEqual({
type: 'message',
message: '/compact does not accept arguments'
});
});
});