mirror of
https://github.com/wu736139669/hapi.git
synced 2026-10-22 22:01:18 +00:00
feat: new codex remote mode
This commit is contained in:
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
};
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user