diff --git a/packages/mindos/src/agent/runtime/codex-app-server.test.ts b/packages/mindos/src/agent/runtime/codex-app-server.test.ts index 901c37e0f..e272e908d 100644 --- a/packages/mindos/src/agent/runtime/codex-app-server.test.ts +++ b/packages/mindos/src/agent/runtime/codex-app-server.test.ts @@ -96,6 +96,27 @@ function createFakeCodexTransport(): CodexAppServerTransport & { sent: unknown[] if (record.method === 'thread/resume') { queue.push({ id: record.id!, result: { thread: { id: record.params?.threadId } } }); } + if (record.method === 'model/list') { + queue.push({ + id: record.id!, + result: { + data: [{ + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + displayName: 'GPT-5.6 Sol', + description: 'Fast coding model', + hidden: false, + isDefault: true, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Fastest' }, + { reasoningEffort: 'ultra', description: 'Maximum reasoning with delegation' }, + ], + defaultReasoningEffort: 'low', + }], + nextCursor: null, + }, + }); + } if (record.method === 'thread/list') { queue.push({ id: record.id!, @@ -276,6 +297,36 @@ describe('agent runtime adapters: Codex app-server', () => { }); }); + it('lists model-advertised reasoning efforts without reducing them to a fixed enum', async () => { + const transport = createFakeCodexTransport(); + const client = createCodexAppServerClient(transport); + + await client.initialize(); + const result = await client.listModels({ includeHidden: true, limit: 100 }); + + expect(result).toEqual({ + data: [{ + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + displayName: 'GPT-5.6 Sol', + description: 'Fast coding model', + hidden: false, + isDefault: true, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Fastest' }, + { reasoningEffort: 'ultra', description: 'Maximum reasoning with delegation' }, + ], + defaultReasoningEffort: 'low', + }], + nextCursor: null, + }); + expect(transport.sent).toContainEqual({ + method: 'model/list', + id: 2, + params: { includeHidden: true, limit: 100 }, + }); + }); + it('lists and reads Codex threads without starting a turn', async () => { const transport = createFakeCodexTransport(); const client = createCodexAppServerClient(transport); diff --git a/packages/mindos/src/agent/runtime/codex-app-server.ts b/packages/mindos/src/agent/runtime/codex-app-server.ts index 5e78454d0..fa36b5d82 100644 --- a/packages/mindos/src/agent/runtime/codex-app-server.ts +++ b/packages/mindos/src/agent/runtime/codex-app-server.ts @@ -135,6 +135,33 @@ export type CodexThreadForkResult = Record & { thread: CodexThread; }; +export type CodexReasoningEffortOption = { + reasoningEffort: string; + description: string; +}; + +export type CodexModel = { + id: string; + model: string; + displayName: string; + description: string; + hidden: boolean; + isDefault: boolean; + supportedReasoningEfforts: CodexReasoningEffortOption[]; + defaultReasoningEffort: string; +}; + +export type CodexModelListInput = { + cursor?: string | null; + limit?: number | null; + includeHidden?: boolean | null; +}; + +export type CodexModelListResult = { + data: CodexModel[]; + nextCursor: string | null; +}; + export type CodexAppServerRequestOptions = { signal?: AbortSignal; }; @@ -143,6 +170,7 @@ export type CodexAppServerClient = { initialize(options?: CodexAppServerRequestOptions): Promise; startThread(input?: { model?: string; cwd?: string }, options?: CodexAppServerRequestOptions): Promise<{ threadId: string }>; resumeThread(input: { threadId: string }, options?: CodexAppServerRequestOptions): Promise<{ threadId: string }>; + listModels(input?: CodexModelListInput): Promise; listThreads(input?: CodexThreadListInput): Promise; readThread(input: { threadId: string; includeTurns?: boolean }): Promise; forkThread(input: CodexThreadForkInput): Promise; @@ -391,6 +419,10 @@ export function createCodexAppServerClient( const result = await request('thread/resume', { threadId: input.threadId }, options.signal); return { threadId: getThreadId(result, 'thread/resume') ?? input.threadId }; }, + async listModels(input = {}) { + const result = await request('model/list', pruneUndefined(input as Record)); + return getModelListResult(result, 'model/list'); + }, async listThreads(input = {}) { const result = await request('thread/list', pruneUndefined(input as Record)); return getThreadListResult(result, 'thread/list'); @@ -742,6 +774,68 @@ function getThread(result: unknown, method: string): CodexThread { return { ...thread, id } as CodexThread; } +function getModelListResult(result: unknown, method: string): CodexModelListResult { + const record = asRecord(result); + if (!record || !Array.isArray(record.data)) { + throw new Error(`Codex app-server ${method} did not return a model list`); + } + return { + data: record.data.map((item, index) => getModel(item, method, index)), + nextCursor: typeof record.nextCursor === 'string' ? record.nextCursor : null, + }; +} + +function getModel(value: unknown, method: string, index: number): CodexModel { + const model = asRecord(value); + if (!model || Array.isArray(value)) { + throw new Error(`Codex app-server ${method} returned an invalid model at index ${index}`); + } + const supportedReasoningEfforts = model.supportedReasoningEfforts; + if (!Array.isArray(supportedReasoningEfforts)) { + throw new Error(`Codex app-server ${method} returned a model without supported reasoning efforts`); + } + return { + id: requiredModelString(model, 'id', method), + model: requiredModelString(model, 'model', method), + displayName: requiredModelString(model, 'displayName', method), + description: requiredModelString(model, 'description', method, true), + hidden: requiredModelBoolean(model, 'hidden', method), + isDefault: requiredModelBoolean(model, 'isDefault', method), + supportedReasoningEfforts: supportedReasoningEfforts.map((option) => { + const effort = asRecord(option); + if (!effort || Array.isArray(option)) { + throw new Error(`Codex app-server ${method} returned an invalid reasoning effort option`); + } + return { + reasoningEffort: requiredModelString(effort, 'reasoningEffort', method), + description: requiredModelString(effort, 'description', method, true), + }; + }), + defaultReasoningEffort: requiredModelString(model, 'defaultReasoningEffort', method), + }; +} + +function requiredModelString( + record: Record, + key: string, + method: string, + allowEmpty = false, +): string { + const value = record[key]; + if (typeof value !== 'string' || (!allowEmpty && !value.trim())) { + throw new Error(`Codex app-server ${method} returned a model with an invalid ${key}`); + } + return value; +} + +function requiredModelBoolean(record: Record, key: string, method: string): boolean { + const value = record[key]; + if (typeof value !== 'boolean') { + throw new Error(`Codex app-server ${method} returned a model with an invalid ${key}`); + } + return value; +} + function getThreadListResult(result: unknown, method: string): CodexThreadListResult { const record = asRecord(result); if (!record || !Array.isArray(record.data)) { diff --git a/packages/mindos/src/agent/runtime/run.ts b/packages/mindos/src/agent/runtime/run.ts index 58277b257..9f9e2e2ae 100644 --- a/packages/mindos/src/agent/runtime/run.ts +++ b/packages/mindos/src/agent/runtime/run.ts @@ -141,7 +141,7 @@ export type MindosNativeAgentTurnOptions = { selectedSkills?: MindosSelectedSkill[]; permissionMode?: MindosPermissionMode; modelOverride?: string; - reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; + reasoningEffort?: string; timeoutMs?: number; runtimeEnv?: NodeJS.ProcessEnv; signal?: AbortSignal; @@ -412,6 +412,9 @@ async function runClaudeTurnWithClient( materialized.attachments, { includeImages: true }, ); + const reasoningEffort = isClaudeReasoningEffort(options.reasoningEffort) + ? options.reasoningEffort + : undefined; const turnEvents = resolvedClient.client.startTurn({ prompt, cwd: options.cwd, @@ -419,7 +422,7 @@ async function runClaudeTurnWithClient( selectedSkills: options.selectedSkills, ...(sessionId ? { sessionId } : {}), ...(options.modelOverride ? { model: options.modelOverride } : {}), - ...(options.reasoningEffort ? { effort: options.reasoningEffort } : {}), + ...(reasoningEffort ? { effort: reasoningEffort } : {}), permissionMode: claudeCliPermissionModeForMindosMode(options.permissionMode), ...(permissionPrompt ? { permissionPrompt } : {}), signal: options.signal, @@ -445,6 +448,10 @@ async function runClaudeTurnWithClient( } } +function isClaudeReasoningEffort(value: string | undefined): value is 'low' | 'medium' | 'high' | 'xhigh' { + return value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh'; +} + function shouldFallbackFromClaudeSdkTurnError(error: Error, signal?: AbortSignal): boolean { if (signal?.aborted) return false; return isClaudeCodeSdkNativeBinaryError(error); diff --git a/packages/mindos/src/client.ts b/packages/mindos/src/client.ts index 34671324e..c14d7396d 100644 --- a/packages/mindos/src/client.ts +++ b/packages/mindos/src/client.ts @@ -94,7 +94,7 @@ export type MindosAgentTurnRequest = { runtimeBinding?: MindosRuntimeSessionBinding | null; selectedAcpAgent?: { id: string; name: string } | null; runtimeOptions?: { - reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; + reasoningEffort?: string; modelOverride?: string; }; providerOverride?: string; diff --git a/packages/mindos/src/server.runtime-web.test.ts b/packages/mindos/src/server.runtime-web.test.ts index d48661483..1a9390f65 100644 --- a/packages/mindos/src/server.runtime-web.test.ts +++ b/packages/mindos/src/server.runtime-web.test.ts @@ -1231,7 +1231,7 @@ describe('MindOS server contract: runtime, agent turn stream, static web', () => }, permissionMode: 'read', runtimeOptions: { - reasoningEffort: 'high', + reasoningEffort: 'ultra', modelOverride: 'gpt-test', }, }, { @@ -1250,7 +1250,7 @@ describe('MindOS server contract: runtime, agent turn stream, static web', () => expect(events).toEqual([ { type: 'status', message: 'context=session-from-path;message=hello from turn;skill=research' }, { type: 'status', message: 'runtime=codex:codex;cwd=/repo/app;space=Research' }, - { type: 'status', message: 'permission=read;effort=high;model=gpt-test' }, + { type: 'status', message: 'permission=read;effort=ultra;model=gpt-test' }, { type: 'done' }, ]); }); diff --git a/packages/mindos/src/server.test.ts b/packages/mindos/src/server.test.ts index 9b71e5af7..44a5d1fae 100644 --- a/packages/mindos/src/server.test.ts +++ b/packages/mindos/src/server.test.ts @@ -378,6 +378,12 @@ describe('MindOS server contract: core, files, HTTP', () => { path: '/api/agent-runtimes/readiness', auth: 'required', }); + expect(contract.routes).toContainEqual({ + id: 'agent-runtimes.codex.models', + method: 'GET', + path: '/api/agent-runtimes/codex/models', + auth: 'required', + }); expect(contract.routes).toContainEqual({ id: 'agent-runtimes.codex.threads', method: 'GET', @@ -1766,6 +1772,7 @@ hidden: true headers: { 'sec-fetch-site': 'same-origin' }, })).status).toBe(200); const protectedCodexRoutes: Array<{ path: string; init?: RequestInit }> = [ + { path: '/api/agent-runtimes/codex/models' }, { path: '/api/agent-runtimes/codex/threads' }, { path: '/api/agent-runtimes/codex/threads/thr-existing' }, { path: '/api/agent-runtimes/codex/threads/thr-existing/fork', init: { method: 'POST', body: '{}' } }, @@ -1811,7 +1818,7 @@ hidden: true } }); - it('dispatches Codex thread manager routes through the Product HTTP server without starting turns', async () => { + it('dispatches Codex model and thread manager routes through the Product HTTP server without starting turns', async () => { const root = mkdtempSync(join(tmpdir(), 'mindos-http-codex-threads-')); const calls: string[] = []; const app = createMindosHttpServer({ @@ -1825,6 +1832,25 @@ hidden: true initialize: async () => { calls.push('initialize'); }, + listModels: async () => { + calls.push('model/list'); + return { + data: [{ + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + displayName: 'GPT-5.6 Sol', + description: 'Fast coding model', + hidden: false, + isDefault: true, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Fastest' }, + { reasoningEffort: 'ultra', description: 'Maximum reasoning with delegation' }, + ], + defaultReasoningEffort: 'low', + }], + nextCursor: null, + }; + }, listThreads: async () => { calls.push('thread/list'); return { @@ -1875,6 +1901,7 @@ hidden: true const auth = { authorization: 'Bearer secret-token' }; try { + const models = await fetch(`${base}/api/agent-runtimes/codex/models`, { headers: auth }); const list = await fetch(`${base}/api/agent-runtimes/codex/threads?limit=10`, { headers: auth }); const read = await fetch(`${base}/api/agent-runtimes/codex/threads/thr-existing?includeTurns=1`, { headers: auth }); const fork = await fetch(`${base}/api/agent-runtimes/codex/threads/thr-existing/fork`, { @@ -1891,6 +1918,14 @@ hidden: true headers: auth, }); + expect(models.status).toBe(200); + expect(await models.json()).toEqual({ + data: [expect.objectContaining({ + id: 'gpt-5.6-sol', + defaultReasoningEffort: 'low', + })], + nextCursor: null, + }); expect(list.status).toBe(200); expect(await list.json()).toEqual({ data: [expect.objectContaining({ id: 'thr-existing' })], @@ -1908,6 +1943,9 @@ hidden: true expect(archive.status).toBe(200); expect(unarchive.status).toBe(200); expect(calls).toEqual([ + 'initialize', + 'model/list', + 'close', 'initialize', 'thread/list', 'close', diff --git a/packages/mindos/src/server/contract.ts b/packages/mindos/src/server/contract.ts index 047e5ffb0..e9318ab9d 100644 --- a/packages/mindos/src/server/contract.ts +++ b/packages/mindos/src/server/contract.ts @@ -58,6 +58,7 @@ export const MINDOS_SERVER_ROUTES: MindosServerRouteContract[] = [ { id: 'agent-runtimes.extensions', method: 'GET', path: '/api/agent-runtimes/extensions', auth: 'required' }, { id: 'agent-runtimes.extensions.preflight', method: 'POST', path: '/api/agent-runtimes/extensions/preflight', auth: 'required' }, { id: 'agent-runtimes.extensions.install', method: 'POST', path: '/api/agent-runtimes/extensions/install', auth: 'required' }, + { id: 'agent-runtimes.codex.models', method: 'GET', path: '/api/agent-runtimes/codex/models', auth: 'required' }, { id: 'agent-runtimes.codex.threads', method: 'GET', path: '/api/agent-runtimes/codex/threads', auth: 'required' }, { id: 'agent-runtimes.codex.thread', method: 'GET', path: '/api/agent-runtimes/codex/threads/[threadId]', auth: 'required' }, { id: 'agent-runtimes.codex.thread.fork', method: 'POST', path: '/api/agent-runtimes/codex/threads/[threadId]/fork', auth: 'required' }, diff --git a/packages/mindos/src/server/handlers/agent-runtimes-codex.test.ts b/packages/mindos/src/server/handlers/agent-runtimes-codex.test.ts index 2608494d2..e9d3221af 100644 --- a/packages/mindos/src/server/handlers/agent-runtimes-codex.test.ts +++ b/packages/mindos/src/server/handlers/agent-runtimes-codex.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; import { + handleCodexModelsGet, handleCodexThreadArchivePost, handleCodexThreadForkPost, handleCodexThreadGet, @@ -16,6 +17,25 @@ function createFakeServices(): CodexThreadManagerServices & { calls: Array<{ met initialize: async () => { calls.push({ method: 'initialize' }); }, + listModels: async (input) => { + calls.push({ method: 'model/list', input }); + return { + data: [{ + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + displayName: 'GPT-5.6 Sol', + description: 'Fast coding model', + hidden: false, + isDefault: true, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Fastest' }, + { reasoningEffort: 'ultra', description: 'Maximum reasoning with delegation' }, + ], + defaultReasoningEffort: 'low', + }], + nextCursor: null, + }; + }, listThreads: async (input) => { calls.push({ method: 'thread/list', input }); return { @@ -84,6 +104,29 @@ function createFakeServices(): CodexThreadManagerServices & { calls: Array<{ met } describe('Codex thread manager product handlers', () => { + it('lists Codex model capabilities without starting a thread or turn', async () => { + const services = createFakeServices(); + const res = await handleCodexModelsGet(services); + + expect(res.status).toBe(200); + expect(res.headers?.['Cache-Control']).toBe('no-store'); + expect(res.body).toEqual({ + data: [expect.objectContaining({ + id: 'gpt-5.6-sol', + defaultReasoningEffort: 'low', + supportedReasoningEfforts: expect.arrayContaining([ + expect.objectContaining({ reasoningEffort: 'ultra' }), + ]), + })], + nextCursor: null, + }); + expect(services.calls).toEqual([ + { method: 'initialize' }, + { method: 'model/list', input: { includeHidden: true, limit: 100 } }, + { method: 'close' }, + ]); + }); + it('lists Codex threads without starting a turn', async () => { const services = createFakeServices(); const res = await handleCodexThreadsGet( diff --git a/packages/mindos/src/server/handlers/agent-runtimes-codex.ts b/packages/mindos/src/server/handlers/agent-runtimes-codex.ts index f58887b54..0a1fd5343 100644 --- a/packages/mindos/src/server/handlers/agent-runtimes-codex.ts +++ b/packages/mindos/src/server/handlers/agent-runtimes-codex.ts @@ -2,6 +2,7 @@ import { createCodexAppServerClient, createCodexAppServerStdioTransport, type CodexAppServerClient, + type CodexModelListResult, type CodexThreadForkInput, type CodexThreadListInput, type CodexThreadListResult, @@ -29,10 +30,26 @@ export type CodexThreadManagerServices = { export type CodexThreadListPayload = CodexThreadListResult; export type CodexThreadReadPayload = CodexThreadReadResult; export type CodexThreadForkPayload = CodexThreadForkResult; +export type CodexModelListPayload = CodexModelListResult; const NO_STORE_HEADERS = { 'Cache-Control': 'no-store' }; const MAX_THREAD_LIST_LIMIT = 100; +export async function handleCodexModelsGet( + services: CodexThreadManagerServices = {}, +): Promise> { + try { + return json(await withCodexClient(services, (client) => client.listModels({ + includeHidden: true, + limit: 100, + })), { + headers: NO_STORE_HEADERS, + }); + } catch (error) { + return codexThreadErrorResponse(error); + } +} + export async function handleCodexThreadsGet( searchParams: URLSearchParams, services: CodexThreadManagerServices = {}, diff --git a/packages/mindos/src/server/handlers/agent-turn.ts b/packages/mindos/src/server/handlers/agent-turn.ts index e79e37161..86ccb4828 100644 --- a/packages/mindos/src/server/handlers/agent-turn.ts +++ b/packages/mindos/src/server/handlers/agent-turn.ts @@ -64,7 +64,7 @@ export type MindosSessionContextSelection = { }; export type MindosNativeRuntimeOptions = { - reasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh'; + reasoningEffort?: string; modelOverride?: string; }; @@ -627,5 +627,5 @@ function isContextAssistantSource(value: unknown): value is NonNullable { - return value === 'low' || value === 'medium' || value === 'high' || value === 'xhigh'; + return typeof value === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(value); } diff --git a/packages/mindos/src/server/http.ts b/packages/mindos/src/server/http.ts index 62ec65baa..42b9e897c 100644 --- a/packages/mindos/src/server/http.ts +++ b/packages/mindos/src/server/http.ts @@ -35,6 +35,7 @@ import { } from './handlers/acp.js'; import { handleAgentActivity, handleAgentActivityPost } from './handlers/agent-activity.js'; import { + handleCodexModelsGet, handleCodexThreadArchivePost, handleCodexThreadForkPost, handleCodexThreadGet, @@ -470,6 +471,10 @@ async function handleRequest( writeResponse(res, await handleCodexThreadsGet(url.searchParams, services)); return; } + if (route === 'GET /api/agent-runtimes/codex/models') { + writeResponse(res, await handleCodexModelsGet(services)); + return; + } const codexThreadRoute = parseCodexThreadRoute(method, url.pathname); if (codexThreadRoute) { if (codexThreadRoute.action === null && method === 'GET') { diff --git a/packages/mindos/src/server/index.ts b/packages/mindos/src/server/index.ts index fe6fb4ada..76f93c72c 100644 --- a/packages/mindos/src/server/index.ts +++ b/packages/mindos/src/server/index.ts @@ -251,11 +251,13 @@ export { } from '../agent/runtime/skill-runtime-requirements.js'; export { + handleCodexModelsGet, handleCodexThreadArchivePost, handleCodexThreadForkPost, handleCodexThreadGet, handleCodexThreadUnarchivePost, handleCodexThreadsGet, + type CodexModelListPayload, type CodexThreadForkPayload, type CodexThreadListPayload, type CodexThreadManagerServices, diff --git a/packages/mindos/src/server/route-ownership.ts b/packages/mindos/src/server/route-ownership.ts index 4e21ce696..ae26d7057 100644 --- a/packages/mindos/src/server/route-ownership.ts +++ b/packages/mindos/src/server/route-ownership.ts @@ -90,6 +90,7 @@ export const MINDOS_WEB_API_ROUTE_OWNERSHIP: MindosWebApiRouteOwnership[] = [ migrated('/api/agent-runtimes/extensions', 'high'), migrated('/api/agent-runtimes/extensions/preflight', 'high'), migrated('/api/agent-runtimes/extensions/install', 'high'), + migrated('/api/agent-runtimes/codex/models', 'medium'), migrated('/api/agent-runtimes/codex/threads', 'medium'), migrated('/api/agent-runtimes/codex/threads/[threadId]', 'medium'), migrated('/api/agent-runtimes/codex/threads/[threadId]/archive', 'medium'), diff --git a/packages/web/__tests__/api/agent-turn-native-runtime.test.ts b/packages/web/__tests__/api/agent-turn-native-runtime.test.ts index 5b429fc42..615b79890 100644 --- a/packages/web/__tests__/api/agent-turn-native-runtime.test.ts +++ b/packages/web/__tests__/api/agent-turn-native-runtime.test.ts @@ -1046,7 +1046,7 @@ describe('/api/agent/sessions/:sessionId/turns native runtime routing', () => { messages: [{ role: 'user', content: 'Organize safely' }], selectedRuntime: { id: 'codex', name: 'Codex', kind: 'codex' }, permissionMode: 'ask', - runtimeOptions: { modelOverride: 'gpt-5.4-codex', reasoningEffort: 'xhigh' }, + runtimeOptions: { modelOverride: 'gpt-5.6-sol', reasoningEffort: 'ultra' }, assistantId: 'inbox-organizer', })); @@ -1054,8 +1054,8 @@ describe('/api/agent/sessions/:sessionId/turns native runtime routing', () => { await res.text(); expect(capturedNativeOptions?.permissionMode).toBe('ask'); - expect(capturedNativeOptions?.modelOverride).toBe('gpt-5.4-codex'); - expect(capturedNativeOptions?.reasoningEffort).toBe('xhigh'); + expect(capturedNativeOptions?.modelOverride).toBe('gpt-5.6-sol'); + expect(capturedNativeOptions?.reasoningEffort).toBe('ultra'); }); it('honors explicit readonly permission for registered assistant preview runs', async () => { diff --git a/packages/web/__tests__/api/codex-thread-manager.test.ts b/packages/web/__tests__/api/codex-thread-manager.test.ts index c8521a749..c3412622f 100644 --- a/packages/web/__tests__/api/codex-thread-manager.test.ts +++ b/packages/web/__tests__/api/codex-thread-manager.test.ts @@ -3,6 +3,7 @@ import { resolve } from 'node:path'; import { describe, expect, it, vi, beforeEach } from 'vitest'; const mockHandleCodexThreadsGet = vi.fn(); +const mockHandleCodexModelsGet = vi.fn(); const mockHandleCodexThreadGet = vi.fn(); const mockHandleCodexThreadForkPost = vi.fn(); const mockHandleCodexThreadArchivePost = vi.fn(); @@ -10,6 +11,7 @@ const mockHandleCodexThreadUnarchivePost = vi.fn(); const mockGetMindRoot = vi.fn(() => '/tmp/mindos-root'); vi.mock('@geminilight/mindos/server', () => ({ + handleCodexModelsGet: mockHandleCodexModelsGet, handleCodexThreadsGet: mockHandleCodexThreadsGet, handleCodexThreadGet: mockHandleCodexThreadGet, handleCodexThreadForkPost: mockHandleCodexThreadForkPost, @@ -23,6 +25,7 @@ vi.mock('@/lib/fs', () => ({ describe('/api/agent-runtimes/codex/threads', () => { beforeEach(() => { + mockHandleCodexModelsGet.mockReset(); mockHandleCodexThreadsGet.mockReset(); mockHandleCodexThreadGet.mockReset(); mockHandleCodexThreadForkPost.mockReset(); @@ -32,6 +35,28 @@ describe('/api/agent-runtimes/codex/threads', () => { mockGetMindRoot.mockReturnValue('/tmp/mindos-root'); }); + it('keeps the Codex model capability route as a thin Product Server adapter', async () => { + mockHandleCodexModelsGet.mockResolvedValue({ + status: 200, + body: { + data: [{ id: 'gpt-5.6-sol', defaultReasoningEffort: 'low' }], + nextCursor: null, + }, + headers: { 'Cache-Control': 'no-store' }, + }); + + const route = await import('../../app/api/agent-runtimes/codex/models/route'); + const response = await route.GET(); + + expect(response.status).toBe(200); + expect(response.headers.get('Cache-Control')).toBe('no-store'); + expect(await response.json()).toEqual({ + data: [{ id: 'gpt-5.6-sol', defaultReasoningEffort: 'low' }], + nextCursor: null, + }); + expect(mockHandleCodexModelsGet).toHaveBeenCalledWith(expect.any(Object)); + }); + it('keeps Codex thread list and read routes as thin Product Server adapters', async () => { mockHandleCodexThreadsGet.mockResolvedValue({ status: 200, @@ -148,6 +173,7 @@ describe('/api/agent-runtimes/codex/threads', () => { it('does not put native process or filesystem ownership in Codex thread Next routes', () => { const root = resolve(__dirname, '../..'); const routes = [ + 'app/api/agent-runtimes/codex/models/route.ts', 'app/api/agent-runtimes/codex/threads/route.ts', 'app/api/agent-runtimes/codex/threads/[threadId]/route.ts', 'app/api/agent-runtimes/codex/threads/[threadId]/fork/route.ts', diff --git a/packages/web/__tests__/ask/native-runtime-options-capsule.test.tsx b/packages/web/__tests__/ask/native-runtime-options-capsule.test.tsx index f3e93e896..fa0651eff 100644 --- a/packages/web/__tests__/ask/native-runtime-options-capsule.test.tsx +++ b/packages/web/__tests__/ask/native-runtime-options-capsule.test.tsx @@ -2,18 +2,66 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import React, { act } from 'react'; import { createRoot } from 'react-dom/client'; -import NativeRuntimeOptionsCapsule from '@/components/ask/NativeRuntimeOptionsCapsule'; - -function renderCapsule(onChange = vi.fn()) { +import NativeRuntimeOptionsCapsule, { + getPersistedNativeRuntimeOptions, + persistNativeRuntimeOptions, +} from '@/components/ask/NativeRuntimeOptionsCapsule'; +import type { AgentRuntimeKind, NativeRuntimeOptions } from '@/lib/types'; + +const codexModels = { + data: [ + { + id: 'gpt-5.6-sol', + model: 'gpt-5.6-sol', + displayName: 'GPT-5.6 Sol', + description: 'Fast coding model', + hidden: false, + isDefault: true, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Low effort' }, + { reasoningEffort: 'medium', description: 'Medium effort' }, + { reasoningEffort: 'high', description: 'High effort' }, + { reasoningEffort: 'xhigh', description: 'Extra high effort' }, + { reasoningEffort: 'max', description: 'Max effort' }, + { reasoningEffort: 'ultra', description: 'Ultra effort with delegation' }, + ], + defaultReasoningEffort: 'low', + }, + { + id: 'gpt-5.6-luna', + model: 'gpt-5.6-luna', + displayName: 'GPT-5.6 Luna', + description: 'General coding model', + hidden: false, + isDefault: false, + supportedReasoningEfforts: [ + { reasoningEffort: 'low', description: 'Low effort' }, + { reasoningEffort: 'medium', description: 'Medium effort' }, + { reasoningEffort: 'high', description: 'High effort' }, + { reasoningEffort: 'xhigh', description: 'Extra high effort' }, + { reasoningEffort: 'max', description: 'Max effort' }, + ], + defaultReasoningEffort: 'medium', + }, + ], + nextCursor: null, +}; + +function renderCapsule(input: { + runtimeKind?: Extract; + value?: NativeRuntimeOptions; + onChange?: ReturnType; +} = {}) { const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); + const onChange = input.onChange ?? vi.fn(); act(() => { root.render( , ); @@ -31,65 +79,107 @@ function renderCapsule(onChange = vi.fn()) { }; } +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); +} + +function clickButtonWithText(scope: ParentNode, text: string) { + const button = Array.from(scope.querySelectorAll('button')) + .find((candidate) => candidate.textContent?.includes(text)) as HTMLButtonElement; + expect(button).toBeTruthy(); + act(() => button.click()); + return button; +} + describe('NativeRuntimeOptionsCapsule', () => { beforeEach(() => { document.body.innerHTML = ''; + localStorage.clear(); + vi.stubGlobal('fetch', vi.fn(async () => new Response(JSON.stringify(codexModels), { + status: 200, + headers: { 'Content-Type': 'application/json' }, + }))); (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; }); - it('uses titled capsule dropdowns for effort and model override', () => { + it('shows the Codex model default without sending it and exposes Sol max and ultra efforts', async () => { const view = renderCapsule(); + await flushEffects(); expect(view.host.textContent).toContain('Default'); - expect(view.host.textContent).not.toContain('Codex default'); - expect(view.host.textContent).not.toContain('Claude default'); - expect(view.host.textContent).toContain('Medium'); + expect(view.host.textContent).toContain('Low (Default)'); + expect(view.onChange).not.toHaveBeenCalled(); - const effortButton = Array.from(view.host.querySelectorAll('button')) - .find((button) => button.textContent?.includes('Medium')) as HTMLButtonElement; - expect(effortButton).toBeTruthy(); + clickButtonWithText(view.host, 'Low (Default)'); - act(() => { - effortButton.click(); - }); + expect(document.body.textContent).toContain('Extra High'); + expect(document.body.textContent).toContain('Max'); + expect(document.body.textContent).toContain('Ultra'); + clickButtonWithText(document.body, 'Ultra'); + expect(view.onChange).toHaveBeenLastCalledWith({ reasoningEffort: 'ultra' }); - expect(document.body.textContent).toContain('Effort'); - expect(document.body.textContent).toContain('High'); + view.cleanup(); + }); - const highOption = Array.from(document.body.querySelectorAll('button')) - .find((button) => button.textContent?.includes('High')) as HTMLButtonElement; - act(() => { - highOption.click(); - }); - expect(view.onChange).toHaveBeenLastCalledWith({ reasoningEffort: 'high' }); + it('uses the selected model capability list and omits unsupported Ultra effort', async () => { + const view = renderCapsule({ value: { modelOverride: 'gpt-5.6-luna' } }); + await flushEffects(); - const modelButton = Array.from(view.host.querySelectorAll('button')) - .find((button) => button.textContent?.includes('Default')) as HTMLButtonElement; - expect(modelButton).toBeTruthy(); + expect(view.host.textContent).toContain('Medium (Default)'); + clickButtonWithText(view.host, 'Medium (Default)'); + expect(document.body.textContent).toContain('Max'); + expect(document.body.textContent).not.toContain('Ultra'); - act(() => { - modelButton.click(); + view.cleanup(); + }); + + it('keeps Claude on its own fixed effort options', async () => { + const view = renderCapsule({ runtimeKind: 'claude' }); + await flushEffects(); + + const effortButton = view.host.querySelectorAll('button')[1] as HTMLButtonElement; + act(() => effortButton.click()); + expect(document.body.textContent).toContain('Extra High'); + const optionLabels = Array.from(document.body.querySelectorAll('[role="option"]')) + .map((option) => option.querySelector('.font-medium')?.textContent); + expect(optionLabels).not.toContain('Max'); + expect(optionLabels).not.toContain('Ultra'); + expect(fetch).not.toHaveBeenCalled(); + + view.cleanup(); + }); + + it('persists model-advertised effort values across reloads', () => { + persistNativeRuntimeOptions('codex', { + modelOverride: 'gpt-5.6-sol', + reasoningEffort: 'ultra', + }); + + expect(getPersistedNativeRuntimeOptions('codex')).toEqual({ + modelOverride: 'gpt-5.6-sol', + reasoningEffort: 'ultra', }); + }); + + it('keeps the model override control behavior', async () => { + const view = renderCapsule(); + await flushEffects(); + clickButtonWithText(view.host, 'Default'); - expect(document.body.textContent).toContain('Model'); const input = document.body.querySelector('input[placeholder="gpt-5.4-codex"]') as HTMLInputElement; expect(input).toBeTruthy(); - act(() => { const valueSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; - valueSetter?.call(input, 'gpt-5.4-codex'); + valueSetter?.call(input, 'gpt-5.6-sol'); input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); }); + clickButtonWithText(document.body, 'Apply'); - const applyButton = Array.from(document.body.querySelectorAll('button')) - .find((button) => button.textContent?.includes('Apply')) as HTMLButtonElement; - act(() => { - applyButton.click(); - }); - - expect(view.onChange).toHaveBeenLastCalledWith({ modelOverride: 'gpt-5.4-codex' }); - + expect(view.onChange).toHaveBeenLastCalledWith({ modelOverride: 'gpt-5.6-sol' }); view.cleanup(); }); }); diff --git a/packages/web/app/api/agent-runtimes/codex/models/route.ts b/packages/web/app/api/agent-runtimes/codex/models/route.ts new file mode 100644 index 000000000..77c89efee --- /dev/null +++ b/packages/web/app/api/agent-runtimes/codex/models/route.ts @@ -0,0 +1,10 @@ +export const dynamic = 'force-dynamic'; +export const runtime = 'nodejs'; + +import { handleCodexModelsGet } from '@geminilight/mindos/server'; +import { toNextResponse } from '@/app/api/_mindos-adapter'; +import { codexThreadServices } from '../_services'; + +export async function GET() { + return toNextResponse(await handleCodexModelsGet(codexThreadServices)); +} diff --git a/packages/web/app/api/agent/_lib/turn-request.ts b/packages/web/app/api/agent/_lib/turn-request.ts index 1487162f4..ff071debf 100644 --- a/packages/web/app/api/agent/_lib/turn-request.ts +++ b/packages/web/app/api/agent/_lib/turn-request.ts @@ -71,10 +71,8 @@ export type AgentTurnRequestContext = { export function normalizeNativeRuntimeOptions(value: unknown): NativeRuntimeOptions { if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; const record = value as Record; - const reasoningEffort = record.reasoningEffort === 'low' - || record.reasoningEffort === 'medium' - || record.reasoningEffort === 'high' - || record.reasoningEffort === 'xhigh' + const reasoningEffort = typeof record.reasoningEffort === 'string' + && /^[a-z][a-z0-9_-]{0,31}$/.test(record.reasoningEffort) ? record.reasoningEffort as NativeRuntimeEffort : undefined; const modelOverride = typeof record.modelOverride === 'string' && record.modelOverride.trim() diff --git a/packages/web/components/ask/NativeRuntimeOptionsCapsule.tsx b/packages/web/components/ask/NativeRuntimeOptionsCapsule.tsx index a8c4d0081..9df10de4e 100644 --- a/packages/web/components/ask/NativeRuntimeOptionsCapsule.tsx +++ b/packages/web/components/ask/NativeRuntimeOptionsCapsule.tsx @@ -10,14 +10,37 @@ import type { } from '@/lib/types'; const STORAGE_PREFIX = 'mindos-native-runtime-options.v1'; -const EFFORT_OPTIONS: NativeRuntimeEffort[] = ['low', 'medium', 'high', 'xhigh']; +const DEFAULT_EFFORT_VALUE = '__default__'; +type CodexModelCapability = { + id: string; + model: string; + isDefault: boolean; + supportedReasoningEfforts: Array<{ reasoningEffort: string; description: string }>; + defaultReasoningEffort: string; +}; +type CodexModelListPayload = { data: CodexModelCapability[]; nextCursor: string | null }; +const FALLBACK_EFFORTS = [ + { reasoningEffort: 'low', description: 'Fastest responses for simple asks.' }, + { reasoningEffort: 'medium', description: 'Balanced reasoning and speed.' }, + { reasoningEffort: 'high', description: 'More reasoning for complex work.' }, + { reasoningEffort: 'xhigh', description: 'Maximum reasoning budget for hard tasks.' }, +]; function storageKey(runtimeKind: AgentRuntimeKind): string { return `${STORAGE_PREFIX}:${runtimeKind}`; } function normalizeEffort(value: unknown): NativeRuntimeEffort | undefined { - return EFFORT_OPTIONS.includes(value as NativeRuntimeEffort) ? value as NativeRuntimeEffort : undefined; + return typeof value === 'string' && /^[a-z][a-z0-9_-]{0,31}$/.test(value) ? value : undefined; +} + +function effortLabel(value: string): string { + if (value === 'xhigh') return 'Extra High'; + return value + .split(/[-_]/) + .filter(Boolean) + .map((part) => `${part[0]?.toUpperCase() ?? ''}${part.slice(1)}`) + .join(' '); } function compactRuntimeOptions(value: NativeRuntimeOptions): NativeRuntimeOptions { @@ -79,9 +102,9 @@ export default function NativeRuntimeOptionsCapsule({ onChange, disabled = false, }: NativeRuntimeOptionsCapsuleProps) { - const effort = value.reasoningEffort ?? 'medium'; const modelOverride = value.modelOverride ?? ''; const [draftModel, setDraftModel] = useState(modelOverride); + const [codexModels, setCodexModels] = useState([]); const inputRef = useRef(null); const defaultModelLabel = 'Default'; const placeholder = runtimeKind === 'codex' ? 'gpt-5.4-codex' : 'sonnet'; @@ -90,25 +113,81 @@ export default function NativeRuntimeOptionsCapsule({ setDraftModel(modelOverride); }, [modelOverride]); + useEffect(() => { + if (runtimeKind !== 'codex') return; + const controller = new AbortController(); + void fetch('/api/agent-runtimes/codex/models', { + cache: 'no-store', + signal: controller.signal, + }).then(async (response) => { + if (!response.ok) return; + const payload = await response.json() as Partial; + if (Array.isArray(payload.data)) setCodexModels(payload.data); + }).catch(() => {}); + return () => controller.abort(); + }, [runtimeKind]); + + const selectedModel = runtimeKind === 'codex' + ? (modelOverride.trim() + ? codexModels.find((model) => model.id === modelOverride.trim() || model.model === modelOverride.trim()) + : codexModels.find((model) => model.isDefault)) + : undefined; + const commit = useCallback((next: NativeRuntimeOptions) => { onChange(compactRuntimeOptions(next)); }, [onChange]); - const setEffort = useCallback((next: NativeRuntimeEffort) => { - commit({ ...value, reasoningEffort: next }); + const setEffort = useCallback((next: string) => { + commit({ + ...value, + reasoningEffort: next === DEFAULT_EFFORT_VALUE ? undefined : next, + }); }, [commit, value]); const commitModel = useCallback((next: string) => { - commit({ ...value, modelOverride: next }); - }, [commit, value]); + const normalized = next.trim(); + const nextModel = runtimeKind === 'codex' + ? (normalized + ? codexModels.find((model) => model.id === normalized || model.model === normalized) + : codexModels.find((model) => model.isDefault)) + : undefined; + const effortSupported = !nextModel || !value.reasoningEffort || nextModel.supportedReasoningEfforts + .some((option) => option.reasoningEffort === value.reasoningEffort); + commit({ + ...value, + modelOverride: normalized, + reasoningEffort: effortSupported ? value.reasoningEffort : undefined, + }); + }, [codexModels, commit, runtimeKind, value]); - const effortOptions: Array> = [ - { value: 'low', label: 'Low', description: 'Fastest responses for simple asks.', icon: effortIcon(13) }, - { value: 'medium', label: 'Medium', description: 'Balanced reasoning and speed.', icon: effortIcon(13) }, - { value: 'high', label: 'High', description: 'More reasoning for complex work.', icon: effortIcon(13) }, - { value: 'xhigh', label: 'X High', description: 'Maximum reasoning budget for hard tasks.', icon: effortIcon(13) }, + const advertisedEfforts = runtimeKind === 'codex' && selectedModel?.supportedReasoningEfforts.length + ? selectedModel.supportedReasoningEfforts + : FALLBACK_EFFORTS; + const defaultEffort = runtimeKind === 'codex' ? selectedModel?.defaultReasoningEffort : undefined; + const defaultLabel = defaultEffort ? `${effortLabel(defaultEffort)} (Default)` : 'Default'; + const effortOptions: Array> = [ + { + value: DEFAULT_EFFORT_VALUE, + label: defaultLabel, + description: defaultEffort + ? `Use the selected model's default ${effortLabel(defaultEffort)} effort.` + : 'Use the runtime default reasoning effort.', + icon: effortIcon(13), + }, + ...advertisedEfforts.map((option) => ({ + value: option.reasoningEffort, + label: effortLabel(option.reasoningEffort), + description: option.description, + icon: effortIcon(13), + })), ]; - const selectedEffort = effortOptions.find((option) => option.value === effort) ?? effortOptions[1]!; + const selectedEffort = value.reasoningEffort + ? effortOptions.find((option) => option.value === value.reasoningEffort) ?? { + value: value.reasoningEffort, + label: effortLabel(value.reasoningEffort), + description: `Use ${effortLabel(value.reasoningEffort)} reasoning effort.`, + } + : effortOptions[0]!; const displayModel = modelOverride.trim() || defaultModelLabel; const shortModel = displayModel.length > 20 ? `${displayModel.slice(0, 18)}...` : displayModel; @@ -190,11 +269,11 @@ export default function NativeRuntimeOptionsCapsule({ icon={effortIcon()} label={selectedEffort.label} tooltip={selectedEffort.description} - value={effort} + value={value.reasoningEffort ?? DEFAULT_EFFORT_VALUE} options={effortOptions} onChange={setEffort} disabled={disabled} - active={effort !== 'medium'} + active={Boolean(value.reasoningEffort)} dropdownWidthClassName="min-w-[230px] max-w-[290px]" /> diff --git a/packages/web/lib/types.ts b/packages/web/lib/types.ts index 32c33516e..6cf331c70 100644 --- a/packages/web/lib/types.ts +++ b/packages/web/lib/types.ts @@ -1154,7 +1154,7 @@ export type AgentMode = 'default' | 'plan' | 'goal'; /** Per-turn permission preset shown in the composer controls. */ export type AgentPermissionMode = 'read' | 'ask' | 'auto' | 'full'; -export type NativeRuntimeEffort = 'low' | 'medium' | 'high' | 'xhigh'; +export type NativeRuntimeEffort = string; export interface NativeRuntimeOptions { modelOverride?: string;