feat: new codex remote mode

This commit is contained in:
weishu
2026-01-26 22:22:32 +08:00
parent 6acde5a9b9
commit 3a1379dc01
8 changed files with 1644 additions and 356 deletions
@@ -0,0 +1,86 @@
import { describe, expect, it } from 'vitest';
import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig';
import { codexSystemPrompt } from './systemPrompt';
describe('appServerConfig', () => {
const mcpServers = { hapi: { command: 'node', args: ['mcp'] } };
it('applies CLI overrides when permission mode is default', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'default' },
mcpServers,
cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' }
});
expect(params.sandbox).toBe('danger-full-access');
expect(params.approvalPolicy).toBe('never');
expect(params.baseInstructions).toBe(codexSystemPrompt);
expect(params.config).toEqual({
'mcp_servers.hapi': {
command: 'node',
args: ['mcp']
}
});
});
it('ignores CLI overrides when permission mode is not default', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'yolo' },
mcpServers,
cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' }
});
expect(params.sandbox).toBe('danger-full-access');
expect(params.approvalPolicy).toBe('on-failure');
});
it('builds turn params with mode defaults', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'read-only', model: 'o3' }
});
expect(params.threadId).toBe('thread-1');
expect(params.input).toEqual([{ type: 'text', text: 'hello' }]);
expect(params.approvalPolicy).toBe('never');
expect(params.sandboxPolicy).toEqual({ type: 'readOnly' });
expect(params.model).toBe('o3');
});
it('applies CLI overrides for turns when permission mode is default', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default' },
cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' }
});
expect(params.approvalPolicy).toBe('never');
expect(params.sandboxPolicy).toEqual({ type: 'dangerFullAccess' });
});
it('ignores CLI overrides for turns when permission mode is not default', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'safe-yolo' },
cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' }
});
expect(params.approvalPolicy).toBe('on-failure');
expect(params.sandboxPolicy).toEqual({ type: 'workspaceWrite' });
});
it('prefers turn overrides', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default' },
overrides: { approvalPolicy: 'on-request', model: 'gpt-5' }
});
expect(params.approvalPolicy).toBe('on-request');
expect(params.model).toBe('gpt-5');
});
});
+145
View File
@@ -0,0 +1,145 @@
import type { EnhancedMode } from '../loop';
import type { CodexCliOverrides } from './codexCliOverrides';
import type { McpServersConfig } from './buildHapiMcpBridge';
import { codexSystemPrompt } from './systemPrompt';
import type {
ApprovalPolicy,
SandboxMode,
SandboxPolicy,
ThreadStartParams,
TurnStartParams
} from '../appServerTypes';
function resolveApprovalPolicy(mode: EnhancedMode): ApprovalPolicy {
switch (mode.permissionMode) {
case 'default': return 'untrusted';
case 'read-only': return 'never';
case 'safe-yolo': return 'on-failure';
case 'yolo': return 'on-failure';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
}
function resolveSandbox(mode: EnhancedMode): SandboxMode {
switch (mode.permissionMode) {
case 'default': return 'workspace-write';
case 'read-only': return 'read-only';
case 'safe-yolo': return 'workspace-write';
case 'yolo': return 'danger-full-access';
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
}
function resolveSandboxPolicy(mode: EnhancedMode): SandboxPolicy {
switch (mode.permissionMode) {
case 'default': return { type: 'workspaceWrite' };
case 'read-only': return { type: 'readOnly' };
case 'safe-yolo': return { type: 'workspaceWrite' };
case 'yolo': return { type: 'dangerFullAccess' };
default: {
throw new Error(`Unknown permission mode: ${mode.permissionMode}`);
}
}
}
function resolveSandboxPolicyOverride(value: CodexCliOverrides['sandbox'] | undefined): SandboxPolicy | undefined {
switch (value) {
case 'read-only':
return { type: 'readOnly' };
case 'workspace-write':
return { type: 'workspaceWrite' };
case 'danger-full-access':
return { type: 'dangerFullAccess' };
default:
return undefined;
}
}
function buildMcpServerConfig(mcpServers: McpServersConfig): Record<string, unknown> {
const config: Record<string, unknown> = {};
for (const [name, server] of Object.entries(mcpServers)) {
config[`mcp_servers.${name}`] = {
command: server.command,
args: server.args
};
}
return config;
}
export function buildThreadStartParams(args: {
mode: EnhancedMode;
mcpServers: McpServersConfig;
cliOverrides?: CodexCliOverrides;
baseInstructions?: string;
developerInstructions?: string;
}): ThreadStartParams {
const approvalPolicy = resolveApprovalPolicy(args.mode);
const sandbox = resolveSandbox(args.mode);
const allowCliOverrides = args.mode.permissionMode === 'default';
const cliOverrides = allowCliOverrides ? args.cliOverrides : undefined;
const resolvedApprovalPolicy = cliOverrides?.approvalPolicy ?? approvalPolicy;
const resolvedSandbox = cliOverrides?.sandbox ?? sandbox;
const config = buildMcpServerConfig(args.mcpServers);
const baseInstructions = args.baseInstructions ?? codexSystemPrompt;
const params: ThreadStartParams = {
approvalPolicy: resolvedApprovalPolicy,
sandbox: resolvedSandbox,
baseInstructions,
...(args.developerInstructions ? { developerInstructions: args.developerInstructions } : {}),
...(Object.keys(config).length > 0 ? { config } : {})
};
if (args.mode.model) {
params.model = args.mode.model;
}
return params;
}
export function buildTurnStartParams(args: {
threadId: string;
message: string;
mode?: EnhancedMode;
cliOverrides?: CodexCliOverrides;
overrides?: {
approvalPolicy?: TurnStartParams['approvalPolicy'];
sandboxPolicy?: TurnStartParams['sandboxPolicy'];
model?: string;
};
}): TurnStartParams {
const params: TurnStartParams = {
threadId: args.threadId,
input: [{ type: 'text', text: args.message }]
};
const allowCliOverrides = args.mode?.permissionMode === 'default';
const cliOverrides = allowCliOverrides ? args.cliOverrides : undefined;
const approvalPolicy = args.overrides?.approvalPolicy
?? cliOverrides?.approvalPolicy
?? (args.mode ? resolveApprovalPolicy(args.mode) : undefined);
if (approvalPolicy) {
params.approvalPolicy = approvalPolicy;
}
const sandboxPolicy = args.overrides?.sandboxPolicy
?? resolveSandboxPolicyOverride(cliOverrides?.sandbox)
?? (args.mode ? resolveSandboxPolicy(args.mode) : undefined);
if (sandboxPolicy) {
params.sandboxPolicy = sandboxPolicy;
}
const model = args.overrides?.model ?? args.mode?.model;
if (model) {
params.model = model;
}
return params;
}
@@ -0,0 +1,85 @@
import { describe, expect, it } from 'vitest';
import { AppServerEventConverter } from './appServerEventConverter';
describe('AppServerEventConverter', () => {
it('maps thread/started', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/started', { thread: { id: 'thread-1' } });
expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-1' }]);
});
it('maps thread/resumed', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('thread/resumed', { thread: { id: 'thread-2' } });
expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-2' }]);
});
it('maps turn/started and completed statuses', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('turn/started', { turn: { id: 'turn-1' } });
expect(started).toEqual([{ type: 'task_started', turn_id: 'turn-1' }]);
const completed = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Completed' });
expect(completed).toEqual([{ type: 'task_complete', turn_id: 'turn-1' }]);
const interrupted = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Interrupted' });
expect(interrupted).toEqual([{ type: 'turn_aborted', turn_id: 'turn-1' }]);
const failed = converter.handleNotification('turn/completed', { turn: { id: 'turn-1' }, status: 'Failed', message: 'boom' });
expect(failed).toEqual([{ type: 'task_failed', turn_id: 'turn-1', error: 'boom' }]);
});
it('accumulates agent message deltas', () => {
const converter = new AppServerEventConverter();
converter.handleNotification('item/agentMessage/delta', { itemId: 'msg-1', delta: 'Hello' });
converter.handleNotification('item/agentMessage/delta', { itemId: 'msg-1', delta: ' world' });
const completed = converter.handleNotification('item/completed', {
item: { id: 'msg-1', type: 'agentMessage' }
});
expect(completed).toEqual([{ type: 'agent_message', message: 'Hello world' }]);
});
it('maps command execution items and output deltas', () => {
const converter = new AppServerEventConverter();
const started = converter.handleNotification('item/started', {
item: { id: 'cmd-1', type: 'commandExecution', command: 'ls' }
});
expect(started).toEqual([{
type: 'exec_command_begin',
call_id: 'cmd-1',
command: 'ls'
}]);
converter.handleNotification('item/commandExecution/outputDelta', { itemId: 'cmd-1', delta: 'ok' });
const completed = converter.handleNotification('item/completed', {
item: { id: 'cmd-1', type: 'commandExecution', exitCode: 0 }
});
expect(completed).toEqual([{
type: 'exec_command_end',
call_id: 'cmd-1',
output: 'ok',
exit_code: 0
}]);
});
it('maps reasoning deltas', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('item/reasoning/textDelta', { itemId: 'r1', delta: 'step' });
expect(events).toEqual([{ type: 'agent_reasoning_delta', delta: 'step' }]);
});
it('maps diff updates', () => {
const converter = new AppServerEventConverter();
const events = converter.handleNotification('turn/diff/updated', { diff: 'diff --git a b' });
expect(events).toEqual([{ type: 'turn_diff', unified_diff: 'diff --git a b' }]);
});
});
@@ -0,0 +1,313 @@
import { logger } from '@/ui/logger';
type ConvertedEvent = {
type: string;
[key: string]: unknown;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
return typeof value === 'string' && value.length > 0 ? value : null;
}
function asBoolean(value: unknown): boolean | null {
return typeof value === 'boolean' ? value : null;
}
function asNumber(value: unknown): number | null {
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function extractItemId(params: Record<string, unknown>): string | null {
const direct = asString(params.itemId ?? params.item_id ?? params.id);
if (direct) return direct;
const item = asRecord(params.item);
if (item) {
return asString(item.id ?? item.itemId ?? item.item_id);
}
return null;
}
function extractItem(params: Record<string, unknown>): Record<string, unknown> | null {
const item = asRecord(params.item);
return item ?? params;
}
function normalizeItemType(value: unknown): string | null {
const raw = asString(value);
if (!raw) return null;
return raw.toLowerCase().replace(/[\s_-]/g, '');
}
function extractCommand(value: unknown): string | null {
if (typeof value === 'string') return value;
if (Array.isArray(value)) {
const parts = value.filter((part): part is string => typeof part === 'string');
return parts.length > 0 ? parts.join(' ') : null;
}
return null;
}
function extractChanges(value: unknown): Record<string, unknown> | null {
const record = asRecord(value);
if (record) return record;
if (Array.isArray(value)) {
const changes: Record<string, unknown> = {};
for (const entry of value) {
const entryRecord = asRecord(entry);
if (!entryRecord) continue;
const path = asString(entryRecord.path ?? entryRecord.file ?? entryRecord.filePath ?? entryRecord.file_path);
if (path) {
changes[path] = entryRecord;
}
}
return Object.keys(changes).length > 0 ? changes : null;
}
return null;
}
export class AppServerEventConverter {
private readonly agentMessageBuffers = new Map<string, string>();
private readonly reasoningBuffers = new Map<string, string>();
private readonly commandOutputBuffers = new Map<string, string>();
private readonly commandMeta = new Map<string, Record<string, unknown>>();
private readonly fileChangeMeta = new Map<string, Record<string, unknown>>();
handleNotification(method: string, params: unknown): ConvertedEvent[] {
const events: ConvertedEvent[] = [];
const paramsRecord = asRecord(params) ?? {};
if (method === 'thread/started' || method === 'thread/resumed') {
const thread = asRecord(paramsRecord.thread) ?? paramsRecord;
const threadId = asString(thread.threadId ?? thread.thread_id ?? thread.id);
if (threadId) {
events.push({ type: 'thread_started', thread_id: threadId });
}
return events;
}
if (method === 'turn/started') {
const turn = asRecord(paramsRecord.turn) ?? paramsRecord;
const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id);
events.push({ type: 'task_started', ...(turnId ? { turn_id: turnId } : {}) });
return events;
}
if (method === 'turn/completed') {
const turn = asRecord(paramsRecord.turn) ?? paramsRecord;
const statusRaw = asString(paramsRecord.status ?? turn.status);
const status = statusRaw?.toLowerCase();
const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id);
const errorMessage = asString(paramsRecord.error ?? paramsRecord.message ?? paramsRecord.reason);
if (status === 'interrupted' || status === 'cancelled' || status === 'canceled') {
events.push({ type: 'turn_aborted', ...(turnId ? { turn_id: turnId } : {}) });
return events;
}
if (status === 'failed' || status === 'error') {
events.push({ type: 'task_failed', ...(turnId ? { turn_id: turnId } : {}), ...(errorMessage ? { error: errorMessage } : {}) });
return events;
}
events.push({ type: 'task_complete', ...(turnId ? { turn_id: turnId } : {}) });
return events;
}
if (method === 'turn/diff/updated') {
const diff = asString(paramsRecord.diff ?? paramsRecord.unified_diff ?? paramsRecord.unifiedDiff);
if (diff) {
events.push({ type: 'turn_diff', unified_diff: diff });
}
return events;
}
if (method === 'thread/tokenUsage/updated') {
const info = asRecord(paramsRecord.tokenUsage ?? paramsRecord.token_usage ?? paramsRecord) ?? {};
events.push({ type: 'token_count', info });
return events;
}
if (method === 'error') {
const willRetry = asBoolean(paramsRecord.will_retry ?? paramsRecord.willRetry) ?? false;
if (willRetry) return events;
const message = asString(paramsRecord.message) ?? asString(asRecord(paramsRecord.error)?.message);
if (message) {
events.push({ type: 'task_failed', error: message });
}
return events;
}
if (method === 'item/agentMessage/delta') {
const itemId = extractItemId(paramsRecord);
const delta = asString(paramsRecord.delta ?? paramsRecord.text ?? paramsRecord.message);
if (itemId && delta) {
const prev = this.agentMessageBuffers.get(itemId) ?? '';
this.agentMessageBuffers.set(itemId, prev + delta);
}
return events;
}
if (method === 'item/reasoning/textDelta') {
const itemId = extractItemId(paramsRecord) ?? 'reasoning';
const delta = asString(paramsRecord.delta ?? paramsRecord.text ?? paramsRecord.message);
if (delta) {
const prev = this.reasoningBuffers.get(itemId) ?? '';
this.reasoningBuffers.set(itemId, prev + delta);
events.push({ type: 'agent_reasoning_delta', delta });
}
return events;
}
if (method === 'item/reasoning/summaryPartAdded') {
events.push({ type: 'agent_reasoning_section_break' });
return events;
}
if (method === 'item/commandExecution/outputDelta') {
const itemId = extractItemId(paramsRecord);
const delta = asString(paramsRecord.delta ?? paramsRecord.text ?? paramsRecord.output ?? paramsRecord.stdout);
if (itemId && delta) {
const prev = this.commandOutputBuffers.get(itemId) ?? '';
this.commandOutputBuffers.set(itemId, prev + delta);
}
return events;
}
if (method === 'item/started' || method === 'item/completed') {
const item = extractItem(paramsRecord);
if (!item) return events;
const itemType = normalizeItemType(item.type ?? item.itemType ?? item.kind);
const itemId = extractItemId(paramsRecord) ?? asString(item.id ?? item.itemId ?? item.item_id);
if (!itemType || !itemId) {
return events;
}
if (itemType === 'agentmessage') {
if (method === 'item/completed') {
const text = asString(item.text ?? item.message ?? item.content) ?? this.agentMessageBuffers.get(itemId);
if (text) {
events.push({ type: 'agent_message', message: text });
}
this.agentMessageBuffers.delete(itemId);
}
return events;
}
if (itemType === 'reasoning') {
if (method === 'item/completed') {
const text = asString(item.text ?? item.message ?? item.content) ?? this.reasoningBuffers.get(itemId);
if (text) {
events.push({ type: 'agent_reasoning', text });
}
this.reasoningBuffers.delete(itemId);
}
return events;
}
if (itemType === 'commandexecution') {
if (method === 'item/started') {
const command = extractCommand(item.command ?? item.cmd ?? item.args);
const cwd = asString(item.cwd ?? item.workingDirectory ?? item.working_directory);
const autoApproved = asBoolean(item.autoApproved ?? item.auto_approved);
const meta: Record<string, unknown> = {};
if (command) meta.command = command;
if (cwd) meta.cwd = cwd;
if (autoApproved !== null) meta.auto_approved = autoApproved;
this.commandMeta.set(itemId, meta);
events.push({
type: 'exec_command_begin',
call_id: itemId,
...meta
});
}
if (method === 'item/completed') {
const meta = this.commandMeta.get(itemId) ?? {};
const output = asString(item.output ?? item.result ?? item.stdout) ?? this.commandOutputBuffers.get(itemId);
const stderr = asString(item.stderr);
const error = asString(item.error);
const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode);
const status = asString(item.status);
events.push({
type: 'exec_command_end',
call_id: itemId,
...meta,
...(output ? { output } : {}),
...(stderr ? { stderr } : {}),
...(error ? { error } : {}),
...(exitCode !== null ? { exit_code: exitCode } : {}),
...(status ? { status } : {})
});
this.commandMeta.delete(itemId);
this.commandOutputBuffers.delete(itemId);
}
return events;
}
if (itemType === 'filechange') {
if (method === 'item/started') {
const changes = extractChanges(item.changes ?? item.change ?? item.diff);
const autoApproved = asBoolean(item.autoApproved ?? item.auto_approved);
const meta: Record<string, unknown> = {};
if (changes) meta.changes = changes;
if (autoApproved !== null) meta.auto_approved = autoApproved;
this.fileChangeMeta.set(itemId, meta);
events.push({
type: 'patch_apply_begin',
call_id: itemId,
...meta
});
}
if (method === 'item/completed') {
const meta = this.fileChangeMeta.get(itemId) ?? {};
const stdout = asString(item.stdout ?? item.output);
const stderr = asString(item.stderr);
const success = asBoolean(item.success ?? item.ok ?? item.applied ?? item.status === 'completed');
events.push({
type: 'patch_apply_end',
call_id: itemId,
...meta,
...(stdout ? { stdout } : {}),
...(stderr ? { stderr } : {}),
success: success ?? false
});
this.fileChangeMeta.delete(itemId);
}
return events;
}
}
logger.debug('[AppServerEventConverter] Unhandled notification', { method, params });
return events;
}
reset(): void {
this.agentMessageBuffers.clear();
this.reasoningBuffers.clear();
this.commandOutputBuffers.clear();
this.commandMeta.clear();
this.fileChangeMeta.clear();
}
}
@@ -0,0 +1,94 @@
import { randomUUID } from 'node:crypto';
import { logger } from '@/ui/logger';
import type { CodexPermissionHandler } from './permissionHandler';
import type { CodexAppServerClient } from '../codexAppServerClient';
type PermissionDecision = 'approved' | 'approved_for_session' | 'denied' | 'abort';
type PermissionResult = {
decision: PermissionDecision;
reason?: string;
};
function asRecord(value: unknown): Record<string, unknown> | null {
if (!value || typeof value !== 'object') {
return null;
}
return value as Record<string, unknown>;
}
function asString(value: unknown): string | undefined {
return typeof value === 'string' && value.length > 0 ? value : undefined;
}
function mapDecision(decision: PermissionDecision): { decision: string } {
switch (decision) {
case 'approved':
return { decision: 'accept' };
case 'approved_for_session':
return { decision: 'acceptForSession' };
case 'denied':
return { decision: 'decline' };
case 'abort':
return { decision: 'cancel' };
}
}
export function registerAppServerPermissionHandlers(args: {
client: CodexAppServerClient;
permissionHandler: CodexPermissionHandler;
onUserInputRequest?: (request: unknown) => Promise<Record<string, string[]>>;
}): void {
const { client, permissionHandler, onUserInputRequest } = args;
client.registerRequestHandler('item/commandExecution/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const reason = asString(record.reason);
const command = record.command;
const cwd = asString(record.cwd);
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexBash',
{
message: reason,
command,
cwd
}
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/fileChange/requestApproval', async (params) => {
const record = asRecord(params) ?? {};
const toolCallId = asString(record.itemId) ?? randomUUID();
const reason = asString(record.reason);
const grantRoot = asString(record.grantRoot);
const result = await permissionHandler.handleToolCall(
toolCallId,
'CodexPatch',
{
message: reason,
grantRoot
}
) as PermissionResult;
return mapDecision(result.decision);
});
client.registerRequestHandler('item/tool/requestUserInput', async (params) => {
if (!onUserInputRequest) {
logger.debug('[CodexAppServer] No user-input handler registered; cancelling request');
return { decision: 'cancel' };
}
const answers = await onUserInputRequest(params);
return {
decision: 'accept',
answers
};
});
}