From d18c01b4b6ff175a4ff5ffcda1dc72f0926fa63a Mon Sep 17 00:00:00 2001 From: weishu Date: Sat, 12 Sep 2026 14:32:14 +0800 Subject: [PATCH] revert(codex): remove Luna Reserve fallback (#1780) Revert 8357da0a9dae10825479f4af2d80b542250d3947 and its later shared-runtime integration. Remove automatic model fallback, account usage polling, and the related UI, protocol fields, tests, and documentation without adding replacement quota handling. Keep generic model pagination and method probing required by the current shared-session architecture. Cover idle sessions staying online without usage polling or agent-state churn. --- cli/README.md | 22 -- cli/src/codex/appServerTypes.ts | 8 - cli/src/codex/codexAppServerClient.test.ts | 20 -- cli/src/codex/codexAppServerClient.ts | 9 - cli/src/codex/codexLocalLauncher.ts | 4 +- cli/src/codex/codexRemoteLauncher.test.ts | 159 +-------- cli/src/codex/codexRemoteLauncher.ts | 102 +----- cli/src/codex/shared/root.test.ts | 22 +- cli/src/codex/shared/root.ts | 21 +- cli/src/codex/utils/appServerConfig.ts | 10 - cli/src/codex/utils/lunaReserve.test.ts | 175 ---------- cli/src/codex/utils/lunaReserve.ts | 301 ------------------ shared/src/schemas.ts | 15 - .../AssistantChat/CodexUsage.test.tsx | 28 -- .../components/AssistantChat/CodexUsage.tsx | 50 --- .../components/AssistantChat/StatusBar.tsx | 4 - web/src/lib/locales/en.ts | 7 - web/src/lib/locales/zh-CN.ts | 7 - 18 files changed, 43 insertions(+), 921 deletions(-) delete mode 100644 cli/src/codex/utils/lunaReserve.test.ts delete mode 100644 cli/src/codex/utils/lunaReserve.ts delete mode 100644 web/src/components/AssistantChat/CodexUsage.test.tsx delete mode 100644 web/src/components/AssistantChat/CodexUsage.tsx diff --git a/cli/README.md b/cli/README.md index 44aaac4e..ca520cee 100644 --- a/cli/README.md +++ b/cli/README.md @@ -276,25 +276,3 @@ bun run build:single-exe - `../hub/README.md` - `../web/README.md` - -### Codex Luna Reserve - -Remote sessions read Codex's account usage through app-server. Luna Reserve is -shown only while the task is using the backend-authorized Reserve route; an -unused Reserve allowance never adds a row or a model option. Ordinary and -Reserve windows remain separate, and unavailable percentages stay unknown. - -HAPI applies the official fallback with `thread/settings/update` and restores -the task's saved model and reasoning effort after a fresh account read permits -ordinary usage. It does not replay the blocked turn. Resume first reconciles -Codex's task settings; the task-local return record uses Codex's -`CODEX_HOME/tui-luna-reserve/.json` format for TUI handoff. - -This requires the newer app-server protocol with `ordinaryUsageAllowed`, -`supportsLunaReserve`, the hidden Reserve catalog entry, and thread settings -updates. Verified against official source -[`ac192cd7937`](https://github.com/openai/codex/tree/ac192cd7937b0d73edc6dffe009940ae53782dd4). -Codex 0.153.4 does not expose the required usage capability; no minimum released -version is claimed. Older servers keep their ordinary usage display without -advertising Reserve activation. Real eligible-account exhaustion, Reserve -exhaustion, and recovery still require account-level validation. diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index dc5e2799..8d6dc4dc 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -387,11 +387,3 @@ export interface ExperimentalFeatureEnablementSetResponse { enablement: Record; [key: string]: unknown; } - -export interface ThreadSettingsUpdateParams { - threadId: string; - model: string; - effort?: string | null; - serviceTier?: string | null; - collaborationMode?: CollaborationMode; -} diff --git a/cli/src/codex/codexAppServerClient.test.ts b/cli/src/codex/codexAppServerClient.test.ts index c2cd395c..b7df81ba 100644 --- a/cli/src/codex/codexAppServerClient.test.ts +++ b/cli/src/codex/codexAppServerClient.test.ts @@ -62,26 +62,6 @@ describe('CodexAppServerClient process cwd', () => { spawnMock.mockReset(); }); - it('uses null for legacy usage reads and opts in only when Reserve is supported', async () => { - const child = fakeChild(); - child.stdin.write = vi.fn((_data: unknown, callback?: (error?: Error | null) => void) => { - callback?.(); - return true; - }); - spawnMock.mockReturnValue(child); - const client = new CodexAppServerClient({ cwd: '/neutral-home' }); - await client.connect(); - for (const supported of [false, true]) { - const pending = client.readAccountRateLimits(supported); - const request = JSON.parse(child.stdin.write.mock.lastCall![0] as string); - expect(request.method).toBe('account/rateLimits/read'); - expect(request.params).toEqual(supported ? { supportsLunaReserve: true } : null); - child.stdout.emit('data', Buffer.from(JSON.stringify({ id: request.id, result: { rateLimits: {} } }) + '\n')); - await expect(pending).resolves.toEqual({ rateLimits: {} }); - } - await client.disconnect(); - }); - it('passes an explicit neutral cwd to the app-server process', async () => { spawnMock.mockReturnValue(fakeChild()); const client = new CodexAppServerClient({ cwd: '/neutral-home' }); diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index 32a72d9c..3460ffa6 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -5,7 +5,6 @@ import { logger } from '@/ui/logger'; import { JsonLineParser } from '@/utils/jsonLineParser'; import { killProcessByChildProcess } from '@/utils/process'; import type { - ThreadSettingsUpdateParams, CollaborationModeListResponse, InitializeParams, InitializeResponse, @@ -356,14 +355,6 @@ export class CodexAppServerClient extends JsonLineParser { return response as ConfigReadResponse; } - async readAccountRateLimits(supportsLunaReserve = false): Promise { - return this.sendRequest('account/rateLimits/read', supportsLunaReserve ? { supportsLunaReserve: true } : null, { timeoutMs: 30_000 }); - } - - async updateThreadSettings(params: ThreadSettingsUpdateParams): Promise { - await this.sendRequest('thread/settings/update', params, { timeoutMs: 30_000 }); - } - async listModels(params?: ModelListParams): Promise { const response = await this.sendRequest('model/list', params ?? {}, { timeoutMs: 30_000 diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index a5368ee8..e50f43d6 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -48,7 +48,7 @@ function extractTurnContextModel(event: CodexSessionEvent): string | null | unde const model = (event.payload as Record).model; if (model === null) return null; if (typeof model !== 'string' || !model.trim()) return undefined; - return model.trim() === 'gpt-reserve' ? 'gpt-5.6-luna' : model.trim(); + return model.trim(); } export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { @@ -445,7 +445,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch await codexLocal({ path: session.path, sessionId: resumeSessionId, - modelReasoningEffort: resumeSessionId ? undefined : (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, + modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, onSessionFound: handleSessionFound, abort: abortSignal, codexArgs, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 3f3fbb6b..83a71a3a 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -1,16 +1,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ - appServerConnected: true, - abandonTransport: null as (() => void) | null, - resumeModel: 'gpt-5.4', - reserveUsage: null as Record | null, - reserveSettingsCalls: [] as Array>, notifications: [] as Array<{ method: string; params: unknown }>, dispatchNotification: null as ((method: string, params: unknown) => void) | null, registerRequestCalls: [] as string[], @@ -112,14 +104,14 @@ vi.mock('./codexAppServerClient', () => { private notificationHandler: ((method: string, params: unknown) => void) | null = null; private stderrHandler: ((text: string) => void) | null = null; - async connect(): Promise { harness.appServerConnected = true; } + async connect(): Promise {} isConnected(): boolean { - return harness.appServerConnected; + return true; } isInitialized(): boolean { - return harness.appServerConnected; + return true; } async initialize(params: unknown): Promise<{ protocolVersion: number }> { @@ -140,7 +132,7 @@ vi.mock('./codexAppServerClient', () => { harness.dispatchNotification = handler; } - setTransportAbandonedHandler(handler: (() => void) | null): void { harness.abandonTransport = handler; } + setTransportAbandonedHandler(_handler: (() => void) | null): void {} setStderrHandler(handler: ((text: string) => void) | null): void { this.stderrHandler = handler; @@ -154,25 +146,6 @@ vi.mock('./codexAppServerClient', () => { return harness.collaborationModeResponse; } - async readAccountRateLimits(): Promise { - if (!harness.reserveUsage) throw new Error('Unsupported'); - return structuredClone(harness.reserveUsage); - } - - async supportsMethod(): Promise { return true; } - - async listModels(): Promise { - return { data: [ - { id: 'gpt-reserve', hidden: true, defaultReasoningEffort: 'medium' }, - { id: 'gpt-5.4', hidden: false, defaultReasoningEffort: 'high' } - ] }; - } - - async updateThreadSettings(params: Record): Promise { - harness.reserveSettingsCalls.push(params); - this.notificationHandler?.('thread/settings/updated', { threadId: params.threadId, threadSettings: params }); - } - async listSkills(params: unknown): Promise { harness.listSkillsCalls.push(params); return harness.skillsListResponse; @@ -205,7 +178,7 @@ vi.mock('./codexAppServerClient', () => { if (harness.failResumeThreadIds.includes(id)) { throw new Error('resume failed'); } - return { thread: { id }, model: harness.resumeModel }; + return { thread: { id }, model: 'gpt-5.4' }; } async compactThread(params?: { threadId?: string }): Promise> { @@ -1136,7 +1109,6 @@ import { INDETERMINATE_SYMBOL } from './codexAppServerClient'; import { RPC_METHODS } from '@hapi/protocol/rpcMethods'; type FakeAgentState = { - codexUsage?: import('@hapi/protocol').AgentState['codexUsage']; requests: Record; completedRequests: Record; }; @@ -1232,10 +1204,6 @@ function createSessionStub( setModelReasoningEffort(nextEffort: EnhancedMode['modelReasoningEffort']) { currentModelReasoningEffort = nextEffort; }, - getModelReasoningEffort() { return currentModelReasoningEffort; }, - pushKeepAlive() {}, - getServiceTier() { return undefined; }, - setServiceTier() {}, getCollaborationMode() { return currentCollaborationMode; }, @@ -1431,11 +1399,6 @@ describe('codexRemoteLauncher', () => { session.queue.close(); }); afterEach(() => { - harness.appServerConnected = true; - harness.abandonTransport = null; - harness.resumeModel = 'gpt-5.4'; - harness.reserveUsage = null; - harness.reserveSettingsCalls = []; harness.notifications = []; harness.dispatchNotification = null; harness.registerRequestCalls = []; @@ -2695,118 +2658,6 @@ describe('codexRemoteLauncher', () => { expect(session.thinking).toBe(false); }); - it('preserves queued model settings when resuming an ordinary thread', async () => { - const { session } = createSessionStub(['first'], { - ...createMode(), model: 'gpt-5.6', modelReasoningEffort: 'high', serviceTier: 'fast' - }, true); - session.sessionId = 'thread-old'; - await codexRemoteLauncher(session as never); - expect(harness.startTurnParams[0]).toMatchObject({ - collaborationMode: { settings: { model: 'gpt-5.6', reasoning_effort: 'high' } }, - serviceTier: 'priority' - }); - }); - - it.each([ - ['transient', 'Codex thread entered systemError', 2, false], - ['context', "Codex ran out of room in the model's context window.", 2, true], - ['usage', 'Usage limit exceeded', 1, false] - ] as const)('preserves %s failure handling during Reserve', async (_kind, error, attempts, compact) => { - harness.resumeModel = 'gpt-reserve'; - harness.remainingThreadSystemErrors = 1; - harness.nextThreadSystemErrorMessage = error; - harness.reserveUsage = { - accountId: 'account-a', ordinaryUsageAllowed: false, - rateLimits: { limitId: 'codex', primary: { usedPercent: 100 } } - }; - const { session } = createSessionStub(['first'], createMode(), true); - session.sessionId = 'thread-reserve'; - await codexRemoteLauncher(session as never); - expect(harness.startTurnMessages).toEqual(Array(attempts).fill('first')); - expect(harness.compactThreadIds).toEqual(compact ? ['thread-reserve'] : []); - expect(harness.startThreadIds).toEqual([]); - expect(harness.startTurnParams[0]).toMatchObject({ - collaborationMode: { settings: { model: 'gpt-reserve' } } - }); - }); - - it('reconciles a resumed Reserve task before waiting for the first user message', async () => { - harness.resumeModel = 'gpt-reserve'; - harness.reserveUsage = { - accountId: 'account-a', ordinaryUsageAllowed: false, - rateLimits: { limitId: 'codex', primary: { usedPercent: 100 } }, - rateLimitsByLimitId: { base_model_inference: { limitName: 'gpt-reserve', primary: { usedPercent: 20 } } } - }; - const { session, getModel, getAgentState } = createSessionStub([], createMode(), false, false); - session.sessionId = 'thread-reserve'; - const run = codexRemoteLauncher(session as never); - try { - await vi.waitFor(() => expect(getAgentState().codexUsage?.reserve?.primary?.remainingPercent).toBe(80)); - expect(getModel()).toBe('gpt-5.6-luna'); - expect(harness.resumeThreadIds).toEqual(['thread-reserve']); - expect(harness.startTurnMessages).toEqual([]); - } finally { - session.queue.close(); - await run; - } - }); - - it('reads native settings after transport loss without replaying the interrupted message', async () => { - harness.resumeModel = 'gpt-reserve'; - harness.suppressTurnCompletion = true; - harness.reserveUsage = { - accountId: 'account-a', ordinaryUsageAllowed: false, - rateLimits: { limitId: 'codex', primary: { usedPercent: 100 } } - }; - const { session, getModel, getAgentState } = createSessionStub(['first'], createMode(), false, false); - session.sessionId = 'thread-reserve'; - const run = codexRemoteLauncher(session as never); - try { - await vi.waitFor(() => expect(harness.startTurnMessages).toEqual(['first'])); - harness.resumeModel = 'gpt-5.4'; - harness.reserveUsage.ordinaryUsageAllowed = true; - harness.appServerConnected = false; - harness.abandonTransport?.(); - await vi.waitFor(() => expect(harness.resumeThreadIds).toEqual(['thread-reserve', 'thread-reserve'])); - await vi.waitFor(() => expect(getAgentState().codexUsage?.reserve).toBeNull()); - expect(getModel()).toBe('gpt-5.4'); - expect(harness.startTurnMessages).toEqual(['first']); - } finally { - session.queue.close(); - await run; - } - }); - - it('uses accepted Reserve settings for queued turns in the same conversation without replay', async () => { - const home = await mkdtemp(join(tmpdir(), 'hapi-reserve-launcher-')); - vi.stubEnv('CODEX_HOME', home); - try { - harness.reserveUsage = { - accountId: 'account-a', ordinaryUsageAllowed: false, - rateLimits: { limitId: 'codex', primary: { usedPercent: 100 } }, - rateLimitsByLimitId: { base_model_inference: { limitName: 'gpt-reserve', primary: { usedPercent: 20 } } }, - rateLimitUpsell: { banner_type: 'luna_reserve', blocked_model_slug: 'gpt-5.4' } - }; - const { session, getModel, sessionEvents } = createSessionStub(['first', 'second'], createMode(), true); - session.sessionId = '01900000-0000-7000-8000-000000000001'; - await codexRemoteLauncher(session as never); - expect(harness.resumeThreadIds).toEqual([session.sessionId]); - expect(harness.resumeThreadParams[0]).not.toHaveProperty('model'); - expect(harness.reserveSettingsCalls).toHaveLength(1); - expect(harness.startTurnMessages).toEqual(['first', 'second']); - expect(harness.startTurnParams).toHaveLength(2); - for (const params of harness.startTurnParams) { - expect(params.collaborationMode).toMatchObject({ settings: { model: 'gpt-reserve', reasoning_effort: 'medium' } }); - expect(params.threadId).toBe(session.sessionId); - } - expect(getModel()).toBe('gpt-5.6-luna'); - expect(sessionEvents.filter(event => String(event.message).includes('Luna Reserve'))).toHaveLength(1); - } finally { - vi.unstubAllEnvs(); - await rm(home, { recursive: true, force: true }); - } - }); - it('does not create a new thread when an existing conversation cannot be resumed', async () => { harness.failResumeThreadIds = ['thread-old']; const { session, sessionEvents } = createSessionStub(['first message']); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index c686d65c..f2290f38 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -18,7 +18,6 @@ import { registerGeneratedImageFromPath } from '@/modules/common/generatedImages import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, - withoutCodexModelOverrides, buildTurnStartParams, type CodexContextManagementConfig } from './utils/appServerConfig'; @@ -39,7 +38,6 @@ import { type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; import { CodexConversationHistory } from './conversationHistory'; -import { LunaReserve } from './utils/lunaReserve'; @@ -361,9 +359,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }); } - private lunaReserve: LunaReserve | null = null; - private usageTimer: ReturnType | null = null; - protected async runMainLoop(): Promise { const session = this.session; const messageBuffer = this.messageBuffer; @@ -1867,7 +1862,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }; - let hasThread = false; let activeMessage: QueuedMessage | null = null; let sameThreadRetryAttempt = 0; let sameThreadCompactAttempt = 0; @@ -1922,9 +1916,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (session.thinking) session.onThinkingChange(false); recoveryInFlight = false; activeMessage = null; - hasThread = false; - this.lunaReserve?.detach(); - this.currentThreadId = null; this.currentTurnId = null; wakeLoop(); }); @@ -2056,7 +2047,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { experimentalApi: true } }); - await this.lunaReserve?.initialize(); }; // Returns 'accepted' when the thread contains the steered user message @@ -2920,9 +2910,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const isThreadStatusFailure = msgType === 'task_failed' && msg.terminal_source === 'thread_status'; const error = msgType === 'task_failed' ? asString(msg.error) : null; const explicitlyNonRetryable = msgType === 'task_failed' - && (msg.retryable === false || isPolicyBlockedCodexFailure(msg, error) - || msg.codex_error_info === 'usageLimitExceeded' - || /usage limit|rate limit|quota/i.test(error ?? '')); + && (msg.retryable === false || isPolicyBlockedCodexFailure(msg, error)); if (deferredThreadStatusFailure && isTerminalEvent && !isThreadStatusFailure) { const sameThread = !eventThreadId || eventThreadId === deferredThreadStatusFailure.threadId; @@ -3504,37 +3492,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }; - const reserve = new LunaReserve(appServerClient, (model, effort, serviceTier) => { - session.setModel(model); - session.setModelReasoningEffort(effort); - if (serviceTier !== undefined) session.setServiceTier(serviceTier); - session.pushKeepAlive(); - }, (codexUsage) => { - session.client.updateAgentState(state => ({ ...state, codexUsage })); - }, message => session.sendSessionEvent({ type: 'message', message })); - this.lunaReserve = reserve; - const currentMode = (): EnhancedMode => ({ - permissionMode: 'default', - model: session.getModel() ?? undefined, - modelReasoningEffort: session.getModelReasoningEffort() ?? undefined, - collaborationMode: session.getCollaborationMode() ?? 'default', - serviceTier: session.getServiceTier() - }); - const refreshUsage = () => reserve.refresh(currentMode(), () => !turnInFlight && !this.shouldExit); - appServerClient.setNotificationHandler((method, params) => { - if (method === 'account/rateLimits/updated' || method === 'account/updated') { - reserve.invalidate(); - void refreshUsage(); - } - if (method === 'thread/settings/updated') { - const record = asRecord(params); - const threadId = asString(record?.threadId); - if (threadId) reserve.onSettings(threadId, record?.threadSettings); - } - if (method === 'error' || method === 'turn/completed') { - reserve.invalidate(); - } if (method === 'skills/changed') { void refreshNativeSkills(true).catch((error) => { logger.debug(`[Codex] failed to refresh skills: ${errorMessage(error)}`); @@ -3564,9 +3522,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }); } } - if (method === 'error' || method === 'turn/completed') { - void (codexEventQueue ?? Promise.resolve()).then(refreshUsage); - } }); appServerClient.setStderrHandler((text) => { @@ -3624,10 +3579,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }); - await reserve.initialize(); - this.usageTimer = setInterval(() => { void refreshUsage(); }, 60_000); - this.usageTimer.unref(); - let contextManagementConfig: CodexContextManagementConfig | undefined; try { const effectiveConfig = (await appServerClient.readConfig({ @@ -3732,6 +3683,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { logger.debug(`[Codex] collaborationMode/list failed: ${errorMessage(error)}`); } + let hasThread = false; let pending: QueuedMessage | null = null; let suppressReadyForAdminCommand = false; @@ -3825,7 +3777,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { try { const resumeResponse = await appServerClient.resumeThread({ threadId: resumeCandidate, - ...withoutCodexModelOverrides(threadParams) + ...threadParams }, { signal: this.abortController.signal }); @@ -3833,7 +3785,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); - reserve.attach(threadId, resumeResponse); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3871,7 +3822,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } }; - const ensureThread = async (mode: EnhancedMode): Promise => { + const ensureThreadForGoal = async (mode: EnhancedMode): Promise => { if (this.currentThreadId && this.currentThreadId !== invalidThreadId) { hasThread = true; return this.currentThreadId; @@ -3891,7 +3842,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { try { const resumeResponse = await appServerClient.resumeThread({ threadId: resumeCandidate, - ...withoutCodexModelOverrides(threadParams) + ...threadParams }, { signal: this.abortController.signal }); @@ -3899,7 +3850,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; const threadId = asString(resumeThread?.id) ?? resumeCandidate; applyResolvedModel(resumeRecord?.model); - reserve.attach(threadId, resumeResponse); this.currentThreadId = threadId; this.conversationHistory.setThreadId(threadId); void this.conversationHistory.probeCapabilities().catch(() => {}); @@ -3907,8 +3857,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { hasThread = true; return threadId; } catch (error) { - logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}`, error); - sendVisibleStatus(`Codex conversation ${resumeCandidate} could not be resumed`); + logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate} for /goal`, error); + sendVisibleStatus(`Goal failed: Codex conversation ${resumeCandidate} could not be resumed`); return null; } } @@ -3931,7 +3881,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const thread = threadRecord ? asRecord(threadRecord.thread) : null; const threadId = asString(thread?.id); applyResolvedModel(threadRecord?.model); - if (threadId) reserve.attach(threadId, threadResponse); if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } @@ -3985,7 +3934,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return true; } - const threadId = await ensureThread(message.mode); + const threadId = await ensureThreadForGoal(message.mode); if (!threadId) { return true; } @@ -4062,7 +4011,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { invalidThreadId = null; hasThread = false; session.resetCodexThread(); - reserve.detach(); sendVisibleStatus('Context was reset'); return true; } @@ -4097,19 +4045,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return true; }; - const reconcileIdleThread = async () => { - if (!hasThread && session.queue.size() === 0 && session.sessionId && session.sessionId !== session.sourceSessionId) { - await ensureThread(currentMode()); - await refreshUsage(); - } - }; - await reconcileIdleThread(); - while (!this.shouldExit) { logActiveHandles('loop-top'); if (!appServerClient.isConnected() || !appServerClient.isInitialized()) { await ensureAppServerInitialized(); - await reconcileIdleThread(); } if (pendingSteerReconciliations.size > 0) { await runSteerReconciliation(); @@ -4197,13 +4136,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const response = shouldForkImportedSource ? await appServerClient.forkThread({ threadId: resumeCandidate, - ...withoutCodexModelOverrides(threadParams) + ...threadParams }, { signal: this.abortController.signal }) : await appServerClient.resumeThread({ threadId: resumeCandidate, - ...withoutCodexModelOverrides(threadParams) + ...threadParams }, { signal: this.abortController.signal }); @@ -4211,7 +4150,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const responseThread = responseRecord ? asRecord(responseRecord.thread) : null; threadId = asString(responseThread?.id) ?? resumeCandidate; applyResolvedModel(responseRecord?.model); - if (threadId) reserve.attach(threadId, response, shouldForkImportedSource ? resumeCandidate : undefined); logger.debug(shouldForkImportedSource ? `[Codex] Forked imported app-server thread ${resumeCandidate} -> ${threadId}` : `[Codex] Resumed app-server thread ${threadId}`); @@ -4237,7 +4175,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const thread = threadRecord ? asRecord(threadRecord.thread) : null; threadId = asString(thread?.id); applyResolvedModel(threadRecord?.model); - if (threadId) reserve.attach(threadId, threadResponse); if (!threadId) { throw new Error('app-server thread/start did not return thread.id'); } @@ -4261,22 +4198,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } - const beforeRefresh = currentMode(); - await reserve.refresh(message.mode, () => !turnInFlight && !this.shouldExit); - const afterRefresh = currentMode(); - const transitioned = beforeRefresh.model !== afterRefresh.model - || beforeRefresh.modelReasoningEffort !== afterRefresh.modelReasoningEffort - || beforeRefresh.serviceTier !== afterRefresh.serviceTier; setTurnInFlight(true); this.conversationHistory.setBusy(true); allowAnonymousTerminalEvent = false; - const mode = reserve.turnMode({ + const mode = { ...message.mode, - model: transitioned ? afterRefresh.model : message.mode.model ?? afterRefresh.model, - modelReasoningEffort: transitioned ? afterRefresh.modelReasoningEffort - : message.mode.modelReasoningEffort ?? afterRefresh.modelReasoningEffort, - serviceTier: transitioned ? afterRefresh.serviceTier : message.mode.serviceTier ?? afterRefresh.serviceTier - }); + model: session.getModel() ?? message.mode.model + }; usageModel = typeof mode.model === 'string' && mode.model.trim() ? mode.model.trim() : null; @@ -4414,10 +4342,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { protected async cleanup(): Promise { logger.debug('[codex-remote]: cleanup start'); - if (this.usageTimer) clearInterval(this.usageTimer); - this.usageTimer = null; - this.lunaReserve?.dispose(); - this.lunaReserve = null; this.appServerClient.setTransportAbandonedHandler(null); this.appServerClient.setStderrHandler(null); try { diff --git a/cli/src/codex/shared/root.test.ts b/cli/src/codex/shared/root.test.ts index 9bfc6725..c69ea530 100644 --- a/cli/src/codex/shared/root.test.ts +++ b/cli/src/codex/shared/root.test.ts @@ -31,7 +31,10 @@ vi.mock('../utils/buildHapiMcpBridge', () => ({ buildHapiMcpBridge: async () => }) })); const cleanups: Array<() => Promise> = []; -afterEach(async () => { for (const cleanup of cleanups.splice(0)) await cleanup(); }); +afterEach(async () => { + try { for (const cleanup of cleanups.splice(0)) await cleanup(); } + finally { vi.useRealTimers(); } +}); async function fixture() { const directory = await mkdtemp('/tmp/hapi-shared-root-'); @@ -45,7 +48,7 @@ async function fixture() { updateAgentState: updateState, keepAlive() {}, onUserMessage() {}, onCancelQueuedMessage() {}, onRetryQueuedMessage() {}, onReconnect: (fn: (() => void) | null) => { reconnect = fn; }, - rpcHandlerManager: { registerHandler() {} }, sendSessionEvent() {}, sendAgentMessage() {}, + rpcHandlerManager: { registerHandler() {} }, sendSessionEvent() {}, sendAgentMessage() {}, emitSessionReady() {}, sendSessionDeath() {}, async flush() {}, close() {} } as unknown as ApiSessionClient; const root = new SharedCodexRoot({ session, workingDirectory: directory } as SessionBootstrapResult, { @@ -66,6 +69,21 @@ async function fixture() { } describe('shared steering availability', () => { + it('keeps idle sessions online without polling usage or publishing agent-state updates', async () => { + const f = await fixture(); + const requests = vi.spyOn(f.root.client, 'request'); + const heartbeat = vi.spyOn(f.root.session, 'keepAlive'); + const updates = f.updateState.mock.calls.length; + vi.useFakeTimers(); + + await f.root.activate(); + await vi.advanceTimersByTimeAsync(5 * 60_000); + + expect(heartbeat).toHaveBeenCalled(); + expect(requests).not.toHaveBeenCalled(); + expect(f.updateState).toHaveBeenCalledTimes(updates); + }); + it('publishes root turn transitions, ignores child turns, and clears on shutdown', async () => { const f = await fixture(); expect(f.state().steeringActive).toBe(false); diff --git a/cli/src/codex/shared/root.ts b/cli/src/codex/shared/root.ts index 9bd5cca0..9e4bbde7 100644 --- a/cli/src/codex/shared/root.ts +++ b/cli/src/codex/shared/root.ts @@ -12,7 +12,6 @@ import { CodexAppServerClient } from '../codexAppServerClient'; import { buildHapiMcpBridge, type HapiMcpBridge } from '../utils/buildHapiMcpBridge'; import { buildUserInputFromMessage } from '../utils/appServerConfig'; import { resolveCodexPermissionModeConfig } from '../utils/permissionModeConfig'; -import { LunaReserve } from '../utils/lunaReserve'; import { resolveCodexSlashCommand } from '../utils/slashCommands'; import { parseReasoningEffortValue } from '../utils/reasoningEffort'; import { SharedCodexPermissions } from './permissions'; @@ -49,9 +48,7 @@ export class SharedCodexRoot { private projection!: SharedCodexProjection; private readonly children = new Map(); private readonly ancestry = new Map(); - private readonly reserve: LunaReserve; private heartbeat?: ReturnType; - private usageTimer?: ReturnType; private work: Promise = Promise.resolve(); private notifications = Promise.resolve(); private currentTurn: string | undefined; @@ -75,9 +72,6 @@ export class SharedCodexRoot { constructor(readonly bootstrap: SessionBootstrapResult, private readonly host: RootHost) { this.session = bootstrap.session; this.client = new CodexAppServerClient({ endpoint: host.endpoint, token: host.token, cwd: bootstrap.workingDirectory }); - this.reserve = new LunaReserve(this.client, - (model, effort, serviceTier) => { this.settings = { ...this.settings, model, modelReasoningEffort: effort, serviceTier }; }, - codexUsage => this.session.updateAgentState(state => ({ ...state, codexUsage })), message => this.notice(message)); this.client.setNotificationHandler((method, params) => { if (method === 'serverRequest/resolved') { this.permissions?.resolved(string(record(params).threadId) ?? '', record(params).requestId); return; @@ -105,7 +99,6 @@ export class SharedCodexRoot { const text = formatMessageWithAttachments(message.content.text, message.content.attachments); const resolved = text.trim().startsWith('/') ? await this.queue.command(id, () => this.command(text)) : text; if (resolved === null) { this.session.emitMessagesConsumed([id], { clearQueuedThinkingGrace: true }); return; } - await this.refreshUsage(); await this.queue.enqueue(id, buildUserInputFromMessage(resolved), this.interrupted); }).catch(error => this.notice(`Message not confirmed: ${error instanceof Error ? error.message : error}. Inspect the queue before retrying.`)); }); @@ -186,7 +179,7 @@ export class SharedCodexRoot { [id, { ...request, completedAt: Date.now(), status: 'canceled' as const }])) } })); if (subscribe) response = record(await this.client.request('thread/resume', { threadId })); - this.acceptSettings(response); this.acceptSettings(this.host.settingsFor(threadId) ?? {}); this.reserve.attach(threadId, response); + this.acceptSettings(response); this.acceptSettings(this.host.settingsFor(threadId) ?? {}); await this.projection.history(response.thread); await this.refresh(); await this.refreshChildren(true); } async activate(options: SharedLaunchOptions = {}): Promise { @@ -195,8 +188,6 @@ export class SharedCodexRoot { if (this.stopping) throw new Error('Codex execution is stopping'); this.registerControls(); this.ready(); this.heartbeat = setInterval(() => this.alive(), 2_000); this.alive(); this.session.emitSessionReady(); - void this.reserve.initialize().catch(error => logger.debug('[Codex shared] usage initialization', error)); - this.usageTimer = setInterval(() => { void this.refreshUsage(); }, 60_000); } private publishSteering(): void { const active = Boolean(this.currentTurn) && !this.stopping && !this.closed && !this.reconnecting && this.client.isInitialized(); @@ -220,7 +211,6 @@ export class SharedCodexRoot { ...('serviceTier' in value ? { serviceTier: value.serviceTier === 'priority' ? 'fast' : 'standard' } : {}), ...('collaborationMode' in value ? { collaborationMode: record(value.collaborationMode).mode === 'plan' ? 'plan' as const : 'default' as const } : {}) }; - this.reserve.onSettings(this.threadId, value); for (const listener of this.settingsListeners) listener(); } private async notification(method: string, params: unknown, modelAtReceipt?: string): Promise { @@ -303,11 +293,6 @@ export class SharedCodexRoot { } catch (error) { logger.debug('[Codex shared] child history unavailable', { id, error }); } } } - private refreshUsage(): Promise { - return this.reserve.refresh({ permissionMode: this.settings.permissionMode === 'yolo' ? 'yolo' : 'default', - collaborationMode: this.settings.collaborationMode ?? 'default', model: this.settings.model ?? undefined }, - () => !this.closed && !this.currentTurn); - } nativeQueueDeleted(nativeId: string): Promise { return this.queue.deleted(nativeId); } replaySettings(): Array<{ method: string; params: unknown }> { return this.settingsNotification ? [{ method: 'thread/settings/updated', params: { @@ -482,9 +467,9 @@ export class SharedCodexRoot { return slash.kind === 'replace' ? slash.text : null; } stopAccepting(): void { - this.stopping = true; this.ready(); clearInterval(this.usageTimer); + this.stopping = true; this.ready(); this.publishSteering(); - this.session.onReconnect(null); this.client.setTransportAbandonedHandler(null); this.reserve.dispose(); + this.session.onReconnect(null); this.client.setTransportAbandonedHandler(null); } async suspend(): Promise { this.stopAccepting(); diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 16c75f5b..fcd70510 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -332,13 +332,3 @@ export function buildTurnStartParams(args: { return params; } - -/** Resume the actual task settings before reconciling HAPI's queued configuration. */ -export function withoutCodexModelOverrides(params: ThreadStartParams): ThreadStartParams { - const { model, serviceTier, ...rest } = params; - const config = { ...rest.config }; - delete config.model; - delete config.model_reasoning_effort; - delete config.service_tier; - return { ...rest, config }; -} diff --git a/cli/src/codex/utils/lunaReserve.test.ts b/cli/src/codex/utils/lunaReserve.test.ts deleted file mode 100644 index 114d0629..00000000 --- a/cli/src/codex/utils/lunaReserve.test.ts +++ /dev/null @@ -1,175 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { mkdtemp, readFile, rm } from 'node:fs/promises'; -import { tmpdir } from 'node:os'; -import { join } from 'node:path'; -import { LunaReserve } from './lunaReserve'; -import { withoutCodexModelOverrides } from './appServerConfig'; -import type { EnhancedMode } from '../loop'; - -const threadId = '01900000-0000-7000-8000-000000000001'; -const mode: EnhancedMode = { permissionMode: 'yolo', collaborationMode: 'plan', model: 'gpt-5.6', modelReasoningEffort: 'high' }; -const dirs: string[] = []; -afterEach(async () => { await Promise.all(dirs.splice(0).map(dir => rm(dir, { recursive: true, force: true }))); }); - -async function fixture() { - const dir = await mkdtemp(join(tmpdir(), 'hapi-reserve-')); - dirs.push(dir); - const response = { - accountId: 'account-a', ordinaryUsageAllowed: true as boolean | null, - rateLimits: { limitId: 'codex', primary: { usedPercent: 40 }, secondary: { usedPercent: 60 }, spendControlReached: false, rateLimitReachedType: null as string | null }, - rateLimitsByLimitId: { - base_model_inference: { limitId: 'base_model_inference', limitName: 'gpt-reserve', primary: { usedPercent: 10 as unknown, resetsAt: 2000000000, windowDurationMins: 123 }, secondary: { usedPercent: 25 } } - }, - rateLimitUpsell: null as unknown - }; - const client = { - readAccountRateLimits: vi.fn(async () => structuredClone(response) as unknown), - supportsMethod: vi.fn(async () => true), - listModels: vi.fn(async () => ({ data: [ - { id: 'gpt-reserve', hidden: true, defaultReasoningEffort: 'medium', supportedReasoningEfforts: [{ reasoningEffort: 'medium' }] }, - { id: 'gpt-5.6', hidden: false, defaultReasoningEffort: 'medium', supportedReasoningEfforts: [{ reasoningEffort: 'high' }] } - ] })), - updateThreadSettings: vi.fn(async () => {}) - }; - const sync = vi.fn(); const publish = vi.fn(); const notice = vi.fn(); - const reserve = new LunaReserve(client, sync, publish, notice, dir); - await reserve.initialize(); - reserve.attach(threadId, { model: 'gpt-5.6', reasoningEffort: 'high', serviceTier: 'priority' }); - const enter = async () => { - response.ordinaryUsageAllowed = false; - response.rateLimitUpsell = { banner_type: 'luna_reserve', blocked_model_slug: 'gpt-5.6' }; - await reserve.refresh(mode, () => true); - }; - const usage = () => publish.mock.lastCall?.[0]; - return { dir, response, client, reserve, sync, publish, notice, enter, usage }; -} - -describe('Luna Reserve authoritative transitions', () => { - it('hides an available bucket and does not infer eligibility from exhausted percentages', async () => { - const f = await fixture(); - f.response.rateLimits.primary.usedPercent = 100; - f.response.rateLimits.secondary.usedPercent = 100; - await f.reserve.refresh(mode, () => true); - expect(f.usage().reserve).toBeNull(); - expect(f.client.updateThreadSettings).not.toHaveBeenCalled(); - f.response.ordinaryUsageAllowed = false; - await f.reserve.refresh(mode, () => true); - expect(f.client.updateThreadSettings).not.toHaveBeenCalled(); - }); - - it('activates only after acceptance, separates buckets, preserves return settings, and hides on recovery', async () => { - const f = await fixture(); - await f.enter(); - expect(f.client.readAccountRateLimits).toHaveBeenLastCalledWith(true); - expect(f.client.updateThreadSettings).toHaveBeenCalledWith({ threadId, model: 'gpt-reserve', effort: 'medium', serviceTier: null, - collaborationMode: { mode: 'plan', settings: { model: 'gpt-reserve', reasoning_effort: 'medium', developer_instructions: null } } }); - expect(f.usage().ordinary.secondary.remainingPercent).toBe(40); - expect(f.usage().reserve.secondary.remainingPercent).toBe(75); - expect(f.usage().reserve.primary).toEqual({ remainingPercent: 90, resetsAt: 2000000000, windowDurationMins: 123 }); - expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6-luna', 'medium', 'standard'); - expect(JSON.parse(await readFile(join(f.dir, 'tui-luna-reserve', `${threadId}.json`), 'utf8'))).toEqual({ account_id: 'account-a', model: 'gpt-5.6', effort: 'high' }); - expect(f.reserve.turnMode(mode)).toMatchObject({ model: 'gpt-reserve', modelReasoningEffort: 'medium', serviceTier: 'standard' }); - f.response.ordinaryUsageAllowed = true; - f.response.rateLimitUpsell = null; - await f.reserve.refresh(mode, () => true); - expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6', 'high', 'standard'); - expect(f.usage().reserve).toBeNull(); - expect(f.notice).toHaveBeenCalledTimes(2); - }); - - it('retains exhausted Reserve without another switch or retry and treats missing/invalid usage as unknown', async () => { - const f = await fixture(); await f.enter(); - for (const used of [undefined, null, '50', NaN, Infinity, -1, 101, 100]) { - f.response.rateLimitsByLimitId.base_model_inference.primary.usedPercent = used; - await f.reserve.refresh(mode, () => true); - expect(f.usage().reserve.primary.remainingPercent).toBe(used === 100 ? 0 : null); - } - expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1); - }); - - it('requires a fresh account-bound recovery read; sparse notifications and unknown banners cannot recover', async () => { - const f = await fixture(); await f.enter(); - f.response.ordinaryUsageAllowed = true; - f.response.rateLimitUpsell = { banner_type: 'unknown_future_banner' }; - await f.reserve.refresh(mode, () => true); - f.response.rateLimitUpsell = null; - f.response.accountId = 'account-b'; - await f.reserve.refresh(mode, () => true); - expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1); - f.response.accountId = 'account-a'; - let finish!: (value: unknown) => void; - f.client.readAccountRateLimits.mockImplementationOnce(() => new Promise(resolve => { finish = resolve; })); - const pending = f.reserve.refresh(mode, () => true); - await vi.waitFor(() => expect(finish).toBeTypeOf('function')); - f.reserve.invalidate(); - finish(f.response); await pending; - expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1); - }); - - it('reconciles a resumed Reserve task with the TUI return target and ignores another thread settings', async () => { - const f = await fixture(); await f.enter(); f.reserve.dispose(); - const resumed = new LunaReserve(f.client, f.sync, f.publish, f.notice, f.dir); - await resumed.initialize(); - resumed.attach(threadId, { model: 'gpt-reserve', reasoningEffort: 'medium' }); - resumed.onSettings('other-thread', { model: 'gpt-5.6', effort: 'high' }); - expect(resumed.turnMode(mode).model).toBe('gpt-reserve'); - f.response.ordinaryUsageAllowed = true; f.response.rateLimitUpsell = null; - await resumed.refresh(mode, () => true); - expect(f.sync).toHaveBeenLastCalledWith('gpt-5.6', 'high', 'standard'); - expect(f.usage().reserve).toBeNull(); - }); - - it('copies the native fork return target without deleting the parent recovery record', async () => { - const f = await fixture(); await f.enter(); - const forkId = '01900000-0000-7000-8000-000000000002'; - f.reserve.attach(forkId, { model: 'gpt-reserve', reasoningEffort: 'medium', thread: { forkedFromId: threadId } }); - await f.reserve.refresh(mode, () => true); - const parent = join(f.dir, 'tui-luna-reserve', `${threadId}.json`); - const child = join(f.dir, 'tui-luna-reserve', `${forkId}.json`); - expect(await readFile(child, 'utf8')).toBe(await readFile(parent, 'utf8')); - f.response.ordinaryUsageAllowed = true; f.response.rateLimitUpsell = null; - await f.reserve.refresh(mode, () => true); - expect(f.client.updateThreadSettings).toHaveBeenLastCalledWith(expect.objectContaining({ threadId: forkId, model: 'gpt-5.6' })); - expect(JSON.parse(await readFile(parent, 'utf8')).model).toBe('gpt-5.6'); - await expect(readFile(child, 'utf8')).rejects.toThrow(); - f.reserve.detach(); - expect(f.usage()).toBeNull(); - }); - - it('keeps selection and visibility unchanged on a rejected settings update without looping', async () => { - const f = await fixture(); - f.client.updateThreadSettings.mockRejectedValue(new Error('Method not found')); - await f.enter(); await f.reserve.refresh(mode, () => true); - expect(f.client.updateThreadSettings).toHaveBeenCalledTimes(1); - expect(f.usage().reserve).toBeNull(); - expect(f.reserve.turnMode(mode).model).toBe('gpt-5.6'); - expect(f.notice).toHaveBeenCalledTimes(1); - }); - - it('does not advertise or activate Reserve on an older protocol', async () => { - const f = await fixture(); - f.client.readAccountRateLimits.mockImplementation(async () => ({ rateLimits: f.response.rateLimits, accountId: 'account-a', rateLimitUpsell: { banner_type: 'luna_reserve' } })); - const old = new LunaReserve(f.client, f.sync, f.publish, f.notice, f.dir); - await old.initialize(); old.attach(threadId, { model: 'gpt-5.6' }); - await old.refresh(mode, () => true); - expect(f.client.readAccountRateLimits).toHaveBeenLastCalledWith(false); - expect(f.client.updateThreadSettings).not.toHaveBeenCalled(); - }); - - it('waits until idle and hides state after exit/disposal', async () => { - const f = await fixture(); - f.response.ordinaryUsageAllowed = false; f.response.rateLimitUpsell = { banner_type: 'luna_reserve' }; - await f.reserve.refresh(mode, () => false); - expect(f.client.updateThreadSettings).not.toHaveBeenCalled(); - await f.enter(); - f.reserve.onSettings(threadId, { model: 'gpt-5.6', effort: 'high' }); - expect(f.usage().reserve).toBeNull(); - f.reserve.dispose(); - expect(f.usage()).toBeNull(); - }); - - it('omits model overrides on resume while retaining permissions, tools and instructions', () => { - expect(withoutCodexModelOverrides({ model: 'stale', serviceTier: 'priority', sandbox: 'workspace-write', config: { model_reasoning_effort: 'high', 'mcp_servers.hapi': {}, developer_instructions: 'hapi' } })) - .toEqual({ sandbox: 'workspace-write', config: { 'mcp_servers.hapi': {}, developer_instructions: 'hapi' } }); - }); -}); diff --git a/cli/src/codex/utils/lunaReserve.ts b/cli/src/codex/utils/lunaReserve.ts deleted file mode 100644 index 629dcd02..00000000 --- a/cli/src/codex/utils/lunaReserve.ts +++ /dev/null @@ -1,301 +0,0 @@ -import { mkdir, open, rename, rm } from 'node:fs/promises'; -import { homedir } from 'node:os'; -import { join } from 'node:path'; -import { randomUUID } from 'node:crypto'; -import { z } from 'zod'; -import { logger } from '@/ui/logger'; -import type { AgentState } from '@hapi/protocol'; -import type { CodexAppServerClient } from '../codexAppServerClient'; -import type { EnhancedMode } from '../loop'; -import type { ModelListItem, ThreadSettingsUpdateParams } from '../appServerTypes'; - -// This quota alias belongs only at the Codex boundary, never in HAPI's model picker. -const RESERVE_MODEL = 'gpt-reserve'; -const LUNA_MODEL = 'gpt-5.6-luna'; -const WindowSchema = z.object({ - usedPercent: z.unknown().optional(), - windowDurationMins: z.unknown().optional(), - resetsAt: z.unknown().optional() -}); -const BucketSchema = z.object({ - limitId: z.string().nullish(), - limitName: z.string().nullish(), - normalModelSlug: z.string().nullish(), - primary: WindowSchema.nullish(), - secondary: WindowSchema.nullish(), - credits: z.object({ hasCredits: z.boolean(), unlimited: z.boolean() }).nullish(), - spendControlReached: z.boolean().nullish(), - rateLimitReachedType: z.string().nullish() -}); -const UsageSchema = z.object({ - accountId: z.string().nullish(), - ordinaryUsageAllowed: z.boolean().nullish(), - rateLimits: BucketSchema, - rateLimitsByLimitId: z.record(z.string(), BucketSchema).nullish(), - rateLimitUpsell: z.unknown().optional() -}); -const BannerSchema = z.object({ - banner_type: z.literal('luna_reserve'), - blocked_model_slug: z.string().min(1).max(256).nullish() -}); -const ReturnSchema = z.object({ - account_id: z.string().min(1), - model: z.string().min(1).max(256), - effort: z.string().nullable() -}); -const SettingsSchema = z.object({ - model: z.string().min(1), - effort: z.string().nullish(), - serviceTier: z.string().nullish() -}); -type Usage = z.infer; -type Bucket = z.infer; -type Settings = z.infer; -type ReserveClient = Pick; - -function usageBucket(bucket?: Bucket | null): NonNullable['ordinary'] { - const window = (value: Bucket['primary']) => { - if (!value) return null; - const finite = (n: unknown): n is number => typeof n === 'number' && Number.isFinite(n); - return { - remainingPercent: finite(value.usedPercent) && value.usedPercent >= 0 && value.usedPercent <= 100 - ? 100 - value.usedPercent : null, - windowDurationMins: finite(value.windowDurationMins) && value.windowDurationMins > 0 ? value.windowDurationMins : null, - resetsAt: finite(value.resetsAt) && value.resetsAt >= 0 ? value.resetsAt : null - }; - }; - return { primary: window(bucket?.primary), secondary: window(bucket?.secondary) }; -} - -/** Account reads authorize transitions; settings accepted by the thread establish activation. */ -export class LunaReserve { - private threadId: string | null = null; - private settings: Settings | null = null; - private settingsRevision = 0; - private forkedFrom: string | null = null; - private snapshot: Usage | null = null; - private models: ModelListItem[] = []; - private supported = false; - private revision = 0; - private disposed = false; - private queue: Promise = Promise.resolve(); - - constructor( - private readonly client: ReserveClient, - private readonly sync: (model: string, effort: string | null, serviceTier: string | null | undefined) => void, - private readonly publish: (usage: AgentState['codexUsage']) => void, - private readonly notice: (message: string) => void, - private readonly codexHome = process.env.CODEX_HOME || join(homedir(), '.codex') - ) {} - - async initialize(): Promise { - this.supported = false; - this.models = []; - try { - const response = UsageSchema.parse(await this.client.readAccountRateLimits()); - this.snapshot = response; - // Field presence is a protocol capability, not account eligibility. - if ('ordinaryUsageAllowed' in response && await this.client.supportsMethod('thread/settings/update')) { - let cursor: string | undefined; - do { - const page = await this.client.listModels({ includeHidden: true, ...(cursor ? { cursor } : {}) }); - this.models.push(...(page.data ?? [])); - cursor = page.nextCursor ?? undefined; - } while (cursor); - this.supported = this.models.some(model => (model.model ?? model.id) === RESERVE_MODEL); - } - this.publishUsage(); - } catch { - // Older app-servers and API-key accounts can lack the usage endpoint. - this.publish(null); - } - } - - attach(threadId: string, response: unknown, forkedFrom?: string): void { - const parsed = z.object({ model: z.string(), reasoningEffort: z.string().nullish(), serviceTier: z.string().nullish(), thread: z.object({ forkedFromId: z.string().nullish() }).optional() }).safeParse(response); - this.threadId = threadId; - this.forkedFrom = forkedFrom ?? (parsed.success ? parsed.data.thread?.forkedFromId : null) ?? null; - this.invalidate(); - this.snapshot = null; - if (parsed.success) { - this.acceptSettings({ ...parsed.data, effort: parsed.data.reasoningEffort }); - } else { - this.settings = null; - } - this.publishUsage(); - } - - onSettings(threadId: string, value: unknown): void { - if (threadId !== this.threadId || this.disposed) return; - const parsed = SettingsSchema.safeParse(value); - if (!parsed.success) return; - this.invalidate(); - this.settingsRevision++; - this.acceptSettings(parsed.data); - this.publishUsage(); - } - - private acceptSettings(settings: Settings): void { - this.settings = settings; - this.sync(settings.model === RESERVE_MODEL ? LUNA_MODEL : settings.model, settings.effort ?? null, - settings.serviceTier === 'priority' ? 'fast' : settings.serviceTier === null ? 'standard' : undefined); - } - - invalidate(): void { - this.revision++; - } - - detach(): void { - this.invalidate(); - this.threadId = null; - this.settings = null; - this.snapshot = null; - this.publishUsage(); - } - - dispose(): void { - this.disposed = true; - this.invalidate(); - this.publish(null); - } - - private publishUsage(): void { - const response = this.snapshot; - if (!response || this.disposed) { - this.publish(null); - return; - } - // Never substitute a model-specific bucket for ordinary account windows. - const ordinary = response.rateLimitsByLimitId?.codex - ?? (!response.rateLimits.limitId || response.rateLimits.limitId === 'codex' ? response.rateLimits : null); - const reserve = Object.values(response.rateLimitsByLimitId ?? {}).find(bucket => bucket.limitName === RESERVE_MODEL) - ?? (response.rateLimits.limitName === RESERVE_MODEL ? response.rateLimits : null); - this.publish({ - ordinary: usageBucket(ordinary), - reserve: this.settings?.model === RESERVE_MODEL && response.accountId - ? usageBucket(reserve) : null - }); - } - - /** Serialize polling with pre-submission reconciliation; never submit or replay a message here. */ - refresh(mode: EnhancedMode, canSwitch: () => boolean): Promise { - this.queue = this.queue.then(() => this.readAndReconcile(mode, canSwitch)); - return this.queue; - } - - private returnPath(threadId = this.threadId): string | null { - if (!z.string().uuid().safeParse(threadId).success) return null; - // Shared with the official TUI so local/remote handoff preserves the task's return target. - return join(this.codexHome, 'tui-luna-reserve', `${threadId}.json`); - } - - private async loadReturn(threadId = this.threadId): Promise | null> { - const path = this.returnPath(threadId); - if (!path) return null; - try { - const handle = await open(path, 'r'); - try { - if ((await handle.stat()).size > 4096) return null; - return ReturnSchema.parse(JSON.parse(await handle.readFile('utf8'))); - } finally { - await handle.close(); - } - } catch { return null; } - } - - private async saveReturn(value: z.infer): Promise { - const path = this.returnPath(); - if (!path) throw new Error('Invalid Codex thread ID'); - await mkdir(join(this.codexHome, 'tui-luna-reserve'), { recursive: true }); - const temporary = `${path}.${randomUUID()}.tmp`; - try { - const handle = await open(temporary, 'wx', 0o600); - try { - await handle.writeFile(JSON.stringify(value)); - await handle.sync(); - } finally { await handle.close(); } - await rename(temporary, path); - } finally { await rm(temporary, { force: true }); } - } - - private async readAndReconcile(mode: EnhancedMode, canSwitch: () => boolean): Promise { - if (this.disposed) return; - const revision = this.revision; - let applying = false; - try { - const response = UsageSchema.parse(await this.client.readAccountRateLimits(this.supported)); - if (this.disposed || revision !== this.revision) return; - this.snapshot = response; - this.publishUsage(); - if (!this.supported || !this.threadId || !this.settings || !response.accountId || !canSwitch()) return; - const current = this.settings; - const inReserve = current.model === RESERVE_MODEL; - let previous = inReserve ? await this.loadReturn() : null; - if (inReserve && !previous && this.forkedFrom) { - const inherited = await this.loadReturn(this.forkedFrom); - if (inherited?.account_id === response.accountId) { - await this.saveReturn(inherited); - previous = inherited; - } - } - const ordinary = response.rateLimits; - const recovered = response.ordinaryUsageAllowed != null - && (response.ordinaryUsageAllowed || ordinary.credits?.hasCredits || ordinary.credits?.unlimited) - && response.rateLimitUpsell == null - && ordinary.spendControlReached !== true && ordinary.rateLimitReachedType == null; - const banner = BannerSchema.safeParse(response.rateLimitUpsell); - const entering = !inReserve && (!mode.model || mode.model === current.model) - && response.ordinaryUsageAllowed === false && banner.success - && (!banner.data.blocked_model_slug || banner.data.blocked_model_slug === current.model); - const targetId = entering ? RESERVE_MODEL - : inReserve && recovered && previous?.account_id === response.accountId ? previous.model : null; - const target = this.models.find(model => (model.model ?? model.id) === targetId && (entering || model.hidden !== true)); - if (!target || !targetId || revision !== this.revision || !canSwitch()) return; - const desiredEffort = entering ? current.effort : previous?.effort; - const effort = target.supportedReasoningEfforts?.some(item => item.reasoningEffort === desiredEffort) - ? desiredEffort : target.defaultReasoningEffort; - applying = true; - if (entering) await this.saveReturn({ account_id: response.accountId, model: current.model, effort: current.effort ?? null }); - if (this.disposed || revision !== this.revision || !canSwitch()) return; - const params: ThreadSettingsUpdateParams = { - threadId: this.threadId, - model: targetId, - effort: effort ?? null, - serviceTier: null, - ...(mode.collaborationMode ? { collaborationMode: { - mode: mode.collaborationMode, - settings: { model: targetId, reasoning_effort: effort ?? null, developer_instructions: null } - } } : {}) - }; - const settingsRevision = this.settingsRevision; - await this.client.updateThreadSettings(params); - if (this.disposed || params.threadId !== this.threadId) return; - if (this.settingsRevision !== settingsRevision - && (this.settings?.model !== targetId || (this.settings.effort ?? null) !== (effort ?? null))) return; - this.acceptSettings({ model: targetId, effort, serviceTier: null }); - if (!entering) { - const path = this.returnPath(); - if (path) await rm(path, { force: true }).catch(error => logger.debug('[Codex] Failed to remove Reserve return record', error)); - } - this.publishUsage(); - this.notice(entering - ? 'Ordinary Codex usage is exhausted. Switched to GPT-5.6 Luna using Luna Reserve.' - : `Ordinary Codex usage is available again. Switched back to ${targetId}.`); - } catch (error) { - logger.debug('[Codex] Reserve reconciliation unavailable', error); - if (applying) { - this.supported = false; - this.notice('Codex could not apply the Reserve transition. Task settings are unchanged; no message was retried.'); - } - this.publishUsage(); - } - } - - turnMode(mode: EnhancedMode): EnhancedMode { - if (this.settings?.model !== RESERVE_MODEL) return mode; - const target = this.models.find(model => (model.model ?? model.id) === RESERVE_MODEL); - const effort = target?.supportedReasoningEfforts?.some(item => item.reasoningEffort === mode.modelReasoningEffort) - ? mode.modelReasoningEffort : this.settings.effort ?? undefined; - return { ...mode, model: RESERVE_MODEL, modelReasoningEffort: effort, serviceTier: 'standard' }; - } -} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index fb5b2ba8..0fa480ea 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -196,22 +196,7 @@ export const AgentStateCompletedRequestSchema = z.object({ export type AgentStateCompletedRequest = z.infer -const CodexUsageWindowSchema = z.object({ - remainingPercent: z.number().min(0).max(100).nullable(), - windowDurationMins: z.number().positive().nullable(), - resetsAt: z.number().nonnegative().nullable() -}) - -const CodexUsageBucketSchema = z.object({ - primary: CodexUsageWindowSchema.nullable(), - secondary: CodexUsageWindowSchema.nullable() -}) - export const AgentStateSchema = z.object({ - codexUsage: z.object({ - ordinary: CodexUsageBucketSchema, - reserve: CodexUsageBucketSchema.nullable() - }).nullish(), controlledByUser: z.boolean().nullish(), // True while the CLI is delivering a queued message into the active turn // (Steer). Surfaced so the web can reflect the inject in progress. diff --git a/web/src/components/AssistantChat/CodexUsage.test.tsx b/web/src/components/AssistantChat/CodexUsage.test.tsx deleted file mode 100644 index 94bf7e9d..00000000 --- a/web/src/components/AssistantChat/CodexUsage.test.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react' -import { expect, it } from 'vitest' -import { I18nProvider } from '@/lib/i18n-context' -import { CodexUsage } from './CodexUsage' -import type { AgentState } from '@/types/api' - -it('shows Reserve only while active, with separate ordinary limits and unknown data', () => { - const usage: NonNullable = { - ordinary: { primary: null, secondary: { remainingPercent: 40, windowDurationMins: null, resetsAt: null } }, - reserve: null - } - const view = () => - const { rerender } = render(view()) - fireEvent.click(screen.getByRole('button', { name: 'Codex Usage' })) - expect(screen.queryByText(/Luna Reserve/)).toBeNull() - expect(screen.getByText('40% remaining')).toBeTruthy() - usage.reserve = { primary: { remainingPercent: 0, windowDurationMins: 123, resetsAt: 2000000000 }, secondary: { remainingPercent: null, windowDurationMins: null, resetsAt: null } } - rerender(view()) - expect(screen.getByText('Luna Reserve · GPT-5.6 Luna')).toBeTruthy() - expect(screen.getByText('40% remaining')).toBeTruthy() - expect(screen.getByText('0% remaining')).toBeTruthy() - expect(screen.getByText('Unknown')).toBeTruthy() - expect(screen.getByText(/123 min window/)).toBeTruthy() - expect(screen.getByText(/Resets/)).toBeTruthy() - usage.reserve = null - rerender(view()) - expect(screen.queryByText(/Luna Reserve/)).toBeNull() -}) diff --git a/web/src/components/AssistantChat/CodexUsage.tsx b/web/src/components/AssistantChat/CodexUsage.tsx deleted file mode 100644 index ef317113..00000000 --- a/web/src/components/AssistantChat/CodexUsage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import * as Popover from '@radix-ui/react-popover' -import type { AgentState } from '@/types/api' -import { useTranslation } from '@/lib/use-translation' - -type Usage = NonNullable - -export function CodexUsage({ usage }: { usage: Usage }) { - const { t } = useTranslation() - const bucket = (value: Usage['ordinary']) => { - const windows = [value.primary, value.secondary].filter(window => window !== null) - if (windows.length === 0) return
{t('codexUsage.unknown')}
- return windows.map((window, index) => ( -
- {window.remainingPercent === null - ? t('codexUsage.unknown') - : t('codexUsage.remaining', { percent: window.remainingPercent })} - {window.windowDurationMins !== null ? ( - · {t('codexUsage.window', { minutes: window.windowDurationMins })} - ) : null} - {window.resetsAt !== null ? ( -
- {t('codexUsage.resets', { time: new Date(window.resetsAt * 1000).toLocaleString() })} -
- ) : null} -
- )) - } - return ( - - - - - - -

{t('codexUsage.title')}

-
{t('codexUsage.ordinary')}
- {bucket(usage.ordinary)} - {usage.reserve ? ( -
-

Luna Reserve · GPT-5.6 Luna

- {bucket(usage.reserve)} -
- ) : null} -
-
-
- ) -} diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index b2f89ee8..5b1f71cb 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -18,7 +18,6 @@ import { shouldShowReasoningStatusLabel } from '@/lib/codexStatusLabels' import { isFastServiceTier } from './codexFastMode' -import { CodexUsage } from './CodexUsage' import { useTranslation } from '@/lib/use-translation' import { useSessionHeaderMetadata } from '@/hooks/useSessionHeaderMetadata' @@ -303,9 +302,6 @@ export function StatusBar(props: { {connectionStatus.text} - {props.agentFlavor === 'codex' && props.agentState?.codexUsage ? ( - - ) : null} {contextUsageLabel ? ( diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f4c6f072..ac237550 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -1,11 +1,4 @@ export default { - 'codexUsage.title': 'Codex Usage', - 'codexUsage.ordinary': 'Ordinary usage', - 'codexUsage.unknown': 'Unknown', - 'codexUsage.remaining': '{percent}% remaining', - 'codexUsage.window': '{minutes} min window', - 'codexUsage.resets': 'Resets {time}', - // Loading states 'loading': 'Loading…', 'authorizing': 'Authorizing…', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index 9f63d9cc..63994476 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -1,11 +1,4 @@ export default { - 'codexUsage.title': 'Codex 用量', - 'codexUsage.ordinary': '常规用量', - 'codexUsage.unknown': '未知', - 'codexUsage.remaining': '剩余 {percent}%', - 'codexUsage.window': '{minutes} 分钟窗口', - 'codexUsage.resets': '重置时间 {time}', - // Loading states 'loading': '加载中…', 'authorizing': '认证中…',