| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| import assert from 'node:assert/strict'; | |
| import { chmod } from 'node:fs/promises'; | |
| import { dirname, join } from 'node:path'; | |
| import test from 'node:test'; | |
| import { fileURLToPath } from 'node:url'; | |
| import plugin, { CodexAppServerClient, EXECUTOR_ID, normalizeConfig } from '../index.mjs'; | |
| const here = dirname(fileURLToPath(import.meta.url)); | |
| const fakeCodex = join(here, 'fixtures', 'fake-codex.mjs'); | |
| const logger = Object.freeze({ warn() {} }); | |
| function request(text, overrides = {}) { | |
| return { | |
| sessionId: 'session-1', | |
| turnId: `turn-${Math.random()}`, | |
| conversationKey: 'session-1', | |
| cwd: process.cwd(), | |
| text, | |
| ...overrides, | |
| }; | |
| } | |
| function executionContext(signal = new AbortController().signal) { | |
| const events = []; | |
| return { context: { signal, emit: (event) => events.push(event) }, events }; | |
| } | |
| test.before(async () => chmod(fakeCodex, 0o755)); | |
| test('package registers the main-compatible executor and lifecycle order', () => { | |
| const calls = []; | |
| let provider; | |
| plugin.host.apply( | |
| { | |
| logger: () => logger, | |
| effect(setup, label) { | |
| calls.push(`effect:${label}`); | |
| setup(); | |
| }, | |
| clientBridge: { | |
| rpc(definition) { | |
| calls.push(`rpc:${definition.name}`); | |
| }, | |
| }, | |
| executors: { | |
| register(value) { | |
| calls.push('register'); | |
| provider = value; | |
| }, | |
| }, | |
| }, | |
| { codexPath: fakeCodex }, | |
| ); | |
| assert.deepEqual(calls, [ | |
| 'effect:codex app-server process', | |
| 'rpc:codex.app-server.models', | |
| 'register', | |
| ]); | |
| assert.equal(provider.id, EXECUTOR_ID); | |
| assert.deepEqual(provider.capabilities, { | |
| thinking: true, | |
| toolActivity: true, | |
| }); | |
| }); | |
| test('configuration validates safe unattended defaults', () => { | |
| assert.deepEqual(normalizeConfig({}), { | |
| codexPath: 'codex', | |
| sandbox: 'read-only', | |
| ephemeralThreads: true, | |
| disposeGraceMs: 3000, | |
| rpcTimeoutMs: 30000, | |
| inheritEnvironmentCredentials: false, | |
| }); | |
| assert.throws(() => normalizeConfig({ sandbox: 'unknown' }), /Unsupported Codex sandbox/u); | |
| assert.throws(() => normalizeConfig({ disposeGraceMs: 1 }), /disposeGraceMs/u); | |
| assert.throws(() => normalizeConfig({ rpcTimeoutMs: 1 }), /rpcTimeoutMs/u); | |
| assert.throws( | |
| () => normalizeConfig({ inheritEnvironmentCredentials: 'yes' }), | |
| /inheritEnvironmentCredentials/u, | |
| ); | |
| }); | |
| test('client lists Codex models and their supported reasoning efforts', async () => { | |
| const client = new CodexAppServerClient({ codexPath: fakeCodex }, logger); | |
| try { | |
| assert.deepEqual(await client.models(), [ | |
| { | |
| model: 'gpt-fake', | |
| displayName: 'GPT Fake', | |
| description: 'Fixture model', | |
| isDefault: true, | |
| defaultReasoningEffort: 'medium', | |
| supportedReasoningEfforts: [ | |
| { reasoningEffort: 'low', description: 'Fast' }, | |
| { reasoningEffort: 'medium', description: 'Balanced' }, | |
| ], | |
| }, | |
| ]); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('client bounds model paging even when every App Server page is malformed', async () => { | |
| const client = new CodexAppServerClient({}, logger); | |
| let requests = 0; | |
| client.ensureStarted = async () => {}; | |
| client.request = async () => { | |
| requests += 1; | |
| return { data: [{}], nextCursor: `cursor-${requests}` }; | |
| }; | |
| assert.deepEqual(await client.models(), []); | |
| assert.equal(requests, 10); | |
| }); | |
| test('client reuses a thread and projects rich events while declining approvals', async () => { | |
| const client = new CodexAppServerClient({ codexPath: fakeCodex }, logger); | |
| try { | |
| const first = executionContext(); | |
| const firstResult = await client.execute(request('first prompt'), first.context); | |
| assert.equal(firstResult.status, 'completed'); | |
| assert.match(firstResult.text, /threadStarts=1, .*approval=decline/u); | |
| assert.deepEqual( | |
| first.events.map((event) => event.type), | |
| ['tool_start', 'tool_progress', 'tool_result', 'thinking_delta', 'output_delta'], | |
| ); | |
| assert.equal(first.events[0].activityKind, 'command'); | |
| assert.equal(first.events[2].isError, true); | |
| const second = executionContext(); | |
| const secondResult = await client.execute(request('second prompt'), second.context); | |
| assert.equal(secondResult.status, 'completed'); | |
| assert.match(secondResult.text, /threadStarts=1, .*approval=decline/u); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('Session model overrides the plugin fallback and every turn refreshes model and effort', async () => { | |
| const client = new CodexAppServerClient( | |
| { codexPath: fakeCodex, model: 'plugin-default' }, | |
| logger, | |
| ); | |
| try { | |
| const first = executionContext(); | |
| const firstResult = await client.execute( | |
| request('first selection', { model: 'gpt-6-terra', reasoningEffort: 'low' }), | |
| first.context, | |
| ); | |
| assert.equal(firstResult.status, 'completed'); | |
| assert.match(firstResult.text, /threadStarts=1, threadModel=gpt-6-terra/u); | |
| assert.match(firstResult.text, /turnModel=gpt-6-terra, turnEffort=low/u); | |
| const second = executionContext(); | |
| const secondResult = await client.execute( | |
| request('changed selection', { model: 'gpt-6-sol', reasoningEffort: 'ultra' }), | |
| second.context, | |
| ); | |
| assert.equal(secondResult.status, 'completed'); | |
| assert.match(secondResult.text, /threadStarts=1, threadModel=gpt-6-terra/u); | |
| assert.match(secondResult.text, /turnModel=gpt-6-sol, turnEffort=ultra/u); | |
| const restored = executionContext(); | |
| const restoredResult = await client.execute( | |
| request('restore effort default', { model: 'gpt-6-sol', reasoningEffort: null }), | |
| restored.context, | |
| ); | |
| assert.equal(restoredResult.status, 'completed'); | |
| assert.match(restoredResult.text, /threadStarts=1, threadModel=gpt-6-terra/u); | |
| assert.match(restoredResult.text, /turnModel=gpt-6-sol, turnEffort=null/u); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('plugin model remains the new-thread fallback when the Session has no model', async () => { | |
| const client = new CodexAppServerClient( | |
| { codexPath: fakeCodex, model: 'plugin-default' }, | |
| logger, | |
| ); | |
| try { | |
| const execution = executionContext(); | |
| const result = await client.execute(request('fallback selection'), execution.context); | |
| assert.equal(result.status, 'completed'); | |
| assert.match(result.text, /threadModel=plugin-default/u); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('client interrupts an active Codex turn on Maka cancellation', async () => { | |
| const client = new CodexAppServerClient({ codexPath: fakeCodex }, logger); | |
| const abort = new AbortController(); | |
| try { | |
| const execution = executionContext(abort.signal); | |
| const resultPromise = client.execute(request('WAIT_FOR_INTERRUPT'), execution.context); | |
| setTimeout(() => abort.abort(new Error('cancel test')), 50); | |
| const result = await resultPromise; | |
| assert.equal(result.status, 'cancelled'); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('client settles cancellation when App Server omits turn/completed', async () => { | |
| const client = new CodexAppServerClient( | |
| { codexPath: fakeCodex, rpcTimeoutMs: 1000, disposeGraceMs: 100 }, | |
| logger, | |
| ); | |
| const abort = new AbortController(); | |
| try { | |
| const execution = executionContext(abort.signal); | |
| const resultPromise = client.execute( | |
| request('WAIT_FOR_INTERRUPT OMIT_COMPLETION'), | |
| execution.context, | |
| ); | |
| setTimeout(() => abort.abort(new Error('cancel test')), 100); | |
| const result = await resultPromise; | |
| assert.equal(result.status, 'cancelled'); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); | |
| test('malformed messages and closed response transports never throw into the Host', () => { | |
| let warnings = 0; | |
| const client = new CodexAppServerClient({}, { warn: () => warnings++ }); | |
| assert.doesNotThrow(() => client.acceptLine('null')); | |
| assert.doesNotThrow(() => | |
| client.respondToServerRequest({ id: 1, method: 'unsupported/request' }), | |
| ); | |
| assert.equal(warnings, 2); | |
| }); | |
| test('attachments are rejected instead of being silently dropped', async () => { | |
| const client = new CodexAppServerClient({ codexPath: fakeCodex }, logger); | |
| try { | |
| const execution = executionContext(); | |
| const result = await client.execute( | |
| request('inspect this', { attachments: [{ id: 'attachment-1' }] }), | |
| execution.context, | |
| ); | |
| assert.deepEqual(result, { | |
| status: 'failed', | |
| message: 'Codex App Server Executor does not support Maka attachment inputs yet', | |
| code: 'codex_attachments_unsupported', | |
| recoverable: false, | |
| }); | |
| } finally { | |
| await client.close(); | |
| } | |
| }); |