Add support for codex plan mode

This commit is contained in:
weishu
2026-03-16 20:48:39 +08:00
parent 329d28a93c
commit 16829b7c78
34 changed files with 688 additions and 97 deletions
+18 -5
View File
@@ -1,6 +1,6 @@
import { ApiClient, ApiSessionClient } from '@/lib';
import { MessageQueue2 } from '@/utils/MessageQueue2';
import type { Metadata, SessionModel, SessionPermissionMode } from '@/api/types';
import type { Metadata, SessionCollaborationMode, SessionModel, SessionPermissionMode } from '@/api/types';
import { logger } from '@/ui/logger';
export type AgentSessionBaseOptions<Mode> = {
@@ -17,6 +17,7 @@ export type AgentSessionBaseOptions<Mode> = {
applySessionIdToMetadata: (metadata: Metadata, sessionId: string) => Metadata;
permissionMode?: SessionPermissionMode;
model?: SessionModel;
collaborationMode?: SessionCollaborationMode;
};
export class AgentSessionBase<Mode> {
@@ -38,6 +39,7 @@ export class AgentSessionBase<Mode> {
private keepAliveInterval: NodeJS.Timeout | null = null;
protected permissionMode?: SessionPermissionMode;
protected model?: SessionModel;
protected collaborationMode?: SessionCollaborationMode;
constructor(opts: AgentSessionBaseOptions<Mode>) {
this.path = opts.path;
@@ -53,6 +55,7 @@ export class AgentSessionBase<Mode> {
this.mode = opts.mode ?? 'local';
this.permissionMode = opts.permissionMode;
this.model = opts.model;
this.collaborationMode = opts.collaborationMode;
this.client.keepAlive(this.thinking, this.mode, this.getKeepAliveRuntime());
this.keepAliveInterval = setInterval(() => {
@@ -71,7 +74,11 @@ export class AgentSessionBase<Mode> {
this.client.keepAlive(this.thinking, mode, this.getKeepAliveRuntime());
const permissionLabel = this.permissionMode ?? 'unset';
const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto');
logger.debug(`[${this.sessionLabel}] Mode switched to ${mode} (permissionMode=${permissionLabel}, model=${modelLabel})`);
const collaborationLabel = this.collaborationMode ?? 'unset';
logger.debug(
`[${this.sessionLabel}] Mode switched to ${mode} ` +
`(permissionMode=${permissionLabel}, model=${modelLabel}, collaborationMode=${collaborationLabel})`
);
this._onModeChange(mode);
};
@@ -103,13 +110,15 @@ export class AgentSessionBase<Mode> {
}
};
protected getKeepAliveRuntime(): { permissionMode?: SessionPermissionMode; model?: SessionModel } | undefined {
if (this.permissionMode === undefined && this.model === undefined) {
protected getKeepAliveRuntime():
{ permissionMode?: SessionPermissionMode; model?: SessionModel; collaborationMode?: SessionCollaborationMode } | undefined {
if (this.permissionMode === undefined && this.model === undefined && this.collaborationMode === undefined) {
return undefined;
}
return {
permissionMode: this.permissionMode,
model: this.model
model: this.model,
collaborationMode: this.collaborationMode
};
}
@@ -120,4 +129,8 @@ export class AgentSessionBase<Mode> {
getModel(): SessionModel | undefined {
return this.model;
}
getCollaborationMode(): SessionCollaborationMode | undefined {
return this.collaborationMode;
}
}
+6 -2
View File
@@ -22,6 +22,7 @@ export type SessionBootstrapOptions = {
tag?: string
agentState?: AgentState | null
model?: string
metadataOverrides?: Partial<Metadata>
}
export type SessionBootstrapResult = {
@@ -51,6 +52,7 @@ export function buildSessionMetadata(options: {
workingDirectory: string
machineId: string
now?: number
metadataOverrides?: Partial<Metadata>
}): Metadata {
const happyLibDir = runtimePath()
const worktreeInfo = readWorktreeEnv()
@@ -72,7 +74,8 @@ export function buildSessionMetadata(options: {
lifecycleState: 'running',
lifecycleStateSince: now,
flavor: options.flavor,
worktree: worktreeInfo ?? undefined
worktree: worktreeInfo ?? undefined,
...options.metadataOverrides
}
}
@@ -119,7 +122,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis
flavor: options.flavor,
startedBy,
workingDirectory,
machineId
machineId,
metadataOverrides: options.metadataOverrides
})
const sessionInfo = await api.getOrCreateSession({
+6 -1
View File
@@ -21,6 +21,7 @@ import type {
MessageContent,
MessageMeta,
Metadata,
SessionCollaborationMode,
Session,
SessionModel,
SessionPermissionMode,
@@ -438,7 +439,11 @@ export class ApiSessionClient extends EventEmitter {
keepAlive(
thinking: boolean,
mode: 'local' | 'remote',
runtime?: { permissionMode?: SessionPermissionMode; model?: SessionModel }
runtime?: {
permissionMode?: SessionPermissionMode
model?: SessionModel
collaborationMode?: SessionCollaborationMode
}
): void {
this.socket.volatile.emit('session-alive', {
sid: this.sessionId,
+6 -2
View File
@@ -1,11 +1,12 @@
import {
AgentStateSchema,
AttachmentMetadataSchema,
CodexCollaborationModeSchema,
MetadataSchema,
PermissionModeSchema,
TodosSchema
} from '@hapi/protocol/schemas'
import type { PermissionMode } from '@hapi/protocol/types'
import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'
import { z } from 'zod'
import { UsageSchema } from '@/claude/types'
@@ -15,11 +16,13 @@ export type {
AgentState,
AttachmentMetadata,
ClaudePermissionMode,
CodexCollaborationMode,
CodexPermissionMode,
Metadata,
Session
} from '@hapi/protocol/types'
export type SessionPermissionMode = PermissionMode
export type SessionCollaborationMode = CodexCollaborationMode
export type SessionModel = string | null
export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema }
@@ -96,7 +99,8 @@ export const CreateSessionResponseSchema = z.object({
thinkingAt: z.number(),
todos: TodosSchema.optional(),
model: z.string().nullable(),
permissionMode: PermissionModeSchema.optional()
permissionMode: PermissionModeSchema.optional(),
collaborationMode: CodexCollaborationModeSchema.optional()
})
})
+13 -2
View File
@@ -1,12 +1,17 @@
export type ApprovalPolicy = 'untrusted' | 'on-failure' | 'on-request' | 'never';
export type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access';
export interface InitializeCapabilities {
experimentalApi: boolean;
}
export interface InitializeParams {
clientInfo: {
name: string;
title?: string;
version: string;
};
capabilities: InitializeCapabilities | null;
}
export interface InitializeResponse {
@@ -32,6 +37,7 @@ export interface ThreadStartResponse {
thread: {
id: string;
};
model: string;
[key: string]: unknown;
}
@@ -56,6 +62,7 @@ export interface ThreadResumeResponse {
thread: {
id: string;
};
model: string;
[key: string]: unknown;
}
@@ -98,8 +105,12 @@ export type ReasoningEffort = 'low' | 'medium' | 'high' | 'auto';
export type ReasoningSummary = 'auto' | 'none' | 'brief' | 'detailed';
export type CollaborationMode = {
mode: 'plan' | 'code' | 'pair_programming' | 'execute' | 'custom' | (string & {});
settings?: Record<string, unknown>;
mode: 'plan' | 'default';
settings: {
model: string;
reasoning_effort?: ReasoningEffort | null;
developer_instructions?: string | null;
};
};
export interface TurnStartParams {
+34 -8
View File
@@ -4,7 +4,8 @@ import type { EnhancedMode } from './loop';
const harness = vi.hoisted(() => ({
notifications: [] as Array<{ method: string; params: unknown }>,
registerRequestCalls: [] as string[]
registerRequestCalls: [] as string[],
initializeCalls: [] as unknown[]
}));
vi.mock('./codexAppServerClient', () => {
@@ -13,7 +14,8 @@ vi.mock('./codexAppServerClient', () => {
async connect(): Promise<void> {}
async initialize(): Promise<{ protocolVersion: number }> {
async initialize(params: unknown): Promise<{ protocolVersion: number }> {
harness.initializeCalls.push(params);
return { protocolVersion: 1 };
}
@@ -25,12 +27,12 @@ vi.mock('./codexAppServerClient', () => {
harness.registerRequestCalls.push(method);
}
async startThread(): Promise<{ thread: { id: string } }> {
return { thread: { id: 'thread-anonymous' } };
async startThread(): Promise<{ thread: { id: string }; model: string }> {
return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' };
}
async resumeThread(): Promise<{ thread: { id: string } }> {
return { thread: { id: 'thread-anonymous' } };
async resumeThread(): Promise<{ thread: { id: string }; model: string }> {
return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' };
}
async startTurn(): Promise<{ turn: Record<string, never> }> {
@@ -73,7 +75,8 @@ type FakeAgentState = {
function createMode(): EnhancedMode {
return {
permissionMode: 'default'
permissionMode: 'default',
collaborationMode: 'default'
};
}
@@ -86,6 +89,7 @@ function createSessionStub() {
const codexMessages: unknown[] = [];
const thinkingChanges: boolean[] = [];
const foundSessionIds: string[] = [];
let currentModel: string | null | undefined;
let agentState: FakeAgentState = {
requests: {},
completedRequests: {}
@@ -119,6 +123,15 @@ function createSessionStub() {
codexCliOverrides: undefined,
sessionId: null as string | null,
thinking: false,
getPermissionMode() {
return 'default' as const;
},
setModel(nextModel: string | null) {
currentModel = nextModel;
},
getModel() {
return currentModel;
},
onThinkingChange(nextThinking: boolean) {
session.thinking = nextThinking;
thinkingChanges.push(nextThinking);
@@ -145,6 +158,7 @@ function createSessionStub() {
thinkingChanges,
foundSessionIds,
rpcHandlers,
getModel: () => currentModel,
getAgentState: () => agentState
};
}
@@ -153,6 +167,7 @@ describe('codexRemoteLauncher', () => {
afterEach(() => {
harness.notifications = [];
harness.registerRequestCalls = [];
harness.initializeCalls = [];
delete process.env.CODEX_USE_MCP_SERVER;
});
@@ -162,13 +177,24 @@ describe('codexRemoteLauncher', () => {
session,
sessionEvents,
thinkingChanges,
foundSessionIds
foundSessionIds,
getModel
} = createSessionStub();
const exitReason = await codexRemoteLauncher(session as never);
expect(exitReason).toBe('exit');
expect(foundSessionIds).toContain('thread-anonymous');
expect(getModel()).toBe('gpt-5.4');
expect(harness.initializeCalls).toEqual([{
clientInfo: {
name: 'hapi-codex-client',
version: '1.0.0'
},
capabilities: {
experimentalApi: true
}
}]);
expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']);
expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1);
expect(thinkingChanges).toContain(true);
+23 -2
View File
@@ -156,6 +156,16 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
return typeof value === 'string' && value.length > 0 ? value : null;
};
const applyResolvedModel = (value: unknown): string | undefined => {
const resolvedModel = asString(value) ?? undefined;
if (!resolvedModel) {
return undefined;
}
session.setModel(resolvedModel);
logger.debug(`[Codex] Resolved app-server model: ${resolvedModel}`);
return resolvedModel;
};
const buildMcpToolName = (server: unknown, tool: unknown): string | null => {
const serverName = asString(server);
const toolName = asString(tool);
@@ -584,6 +594,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
clientInfo: {
name: 'hapi-codex-client',
version: '1.0.0'
},
capabilities: {
experimentalApi: true
}
});
} else if (mcpClient) {
@@ -680,6 +693,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const resumeRecord = asRecord(resumeResponse);
const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null;
threadId = asString(resumeThread?.id) ?? resumeCandidate;
applyResolvedModel(resumeRecord?.model);
logger.debug(`[Codex] Resumed app-server thread ${threadId}`);
} catch (error) {
logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}, starting new thread`, error);
@@ -693,6 +707,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const threadRecord = asRecord(threadResponse);
const thread = threadRecord ? asRecord(threadRecord.thread) : null;
threadId = asString(thread?.id);
applyResolvedModel(threadRecord?.model);
if (!threadId) {
throw new Error('app-server thread/start did not return thread.id');
}
@@ -708,7 +723,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const turnParams = buildTurnStartParams({
threadId,
message: message.message,
mode: message.mode,
mode: {
...message.mode,
model: session.getModel() ?? message.mode.model
},
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
@@ -750,7 +768,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase {
const turnParams = buildTurnStartParams({
threadId: this.currentThreadId,
message: message.message,
mode: message.mode,
mode: {
...message.mode,
model: session.getModel() ?? message.mode.model
},
cliOverrides: session.codexCliOverrides
});
turnInFlight = true;
+7 -4
View File
@@ -6,15 +6,14 @@ import { codexLocalLauncher } from './codexLocalLauncher';
import { codexRemoteLauncher } from './codexRemoteLauncher';
import { ApiClient, ApiSessionClient } from '@/lib';
import type { CodexCliOverrides } from './utils/codexCliOverrides';
import type { CodexPermissionMode } from '@hapi/protocol/types';
import type { CollaborationMode } from './appServerTypes';
import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types';
export type PermissionMode = CodexPermissionMode;
export interface EnhancedMode {
permissionMode: PermissionMode;
model?: string;
collaborationMode?: CollaborationMode['mode'];
collaborationMode: CodexCollaborationMode;
}
interface LoopOptions {
@@ -28,6 +27,8 @@ interface LoopOptions {
codexArgs?: string[];
codexCliOverrides?: CodexCliOverrides;
permissionMode?: PermissionMode;
model?: string;
collaborationMode?: CodexCollaborationMode;
resumeSessionId?: string;
onSessionReady?: (session: CodexSession) => void;
}
@@ -49,7 +50,9 @@ export async function loop(opts: LoopOptions): Promise<void> {
startingMode,
codexArgs: opts.codexArgs,
codexCliOverrides: opts.codexCliOverrides,
permissionMode: opts.permissionMode ?? 'default'
permissionMode: opts.permissionMode ?? 'default',
model: opts.model,
collaborationMode: opts.collaborationMode ?? 'default'
});
await runLocalRemoteSession({
+44 -13
View File
@@ -9,7 +9,7 @@ import { parseCodexCliOverrides } from './utils/codexCliOverrides';
import { bootstrapSession } from '@/agent/sessionFactory';
import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle';
import { isPermissionModeAllowedForFlavor } from '@hapi/protocol';
import { PermissionModeSchema } from '@hapi/protocol/schemas';
import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas';
import { formatMessageWithAttachments } from '@/utils/attachmentFormatter';
export { emitReadyIfIdle } from './utils/emitReadyIfIdle';
@@ -23,6 +23,7 @@ export async function runCodex(opts: {
}): Promise<void> {
const workingDirectory = process.cwd();
const startedBy = opts.startedBy ?? 'terminal';
const codexRemoteBackend = process.env.CODEX_USE_MCP_SERVER === '1' ? 'mcp-server' : 'app-server';
logger.debug(`[codex] Starting with options: startedBy=${startedBy}`);
@@ -34,7 +35,10 @@ export async function runCodex(opts: {
startedBy,
workingDirectory,
agentState: state,
model: opts.model
model: opts.model,
metadataOverrides: {
codexRemoteBackend
}
});
const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local';
@@ -51,8 +55,8 @@ export async function runCodex(opts: {
const sessionWrapperRef: { current: CodexSession | null } = { current: null };
let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default';
const currentModel = opts.model;
let currentCollaborationMode: EnhancedMode['collaborationMode'];
let currentModel = opts.model;
let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default';
const lifecycle = createRunnerLifecycle({
session,
@@ -68,13 +72,38 @@ export async function runCodex(opts: {
if (!sessionInstance) {
return;
}
const sessionModel = sessionInstance.getModel();
if (sessionModel !== undefined) {
currentModel = sessionModel ?? undefined;
}
sessionInstance.setPermissionMode(currentPermissionMode);
logger.debug(`[Codex] Synced session permission mode for keepalive: ${currentPermissionMode}`);
sessionInstance.setModel(currentModel ?? null);
sessionInstance.setCollaborationMode(currentCollaborationMode);
logger.debug(
`[Codex] Synced session config for keepalive: ` +
`permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, collaborationMode=${currentCollaborationMode}`
);
};
session.onUserMessage((message) => {
const sessionPermissionMode = sessionWrapperRef.current?.getPermissionMode();
if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'codex')) {
currentPermissionMode = sessionPermissionMode as PermissionMode;
}
const sessionModel = sessionWrapperRef.current?.getModel();
if (sessionModel !== undefined) {
currentModel = sessionModel ?? undefined;
}
const sessionCollaborationMode = sessionWrapperRef.current?.getCollaborationMode();
if (sessionCollaborationMode) {
currentCollaborationMode = sessionCollaborationMode;
}
const messagePermissionMode = currentPermissionMode;
logger.debug(`[Codex] User message received with permission mode: ${currentPermissionMode}`);
logger.debug(
`[Codex] User message received with permission mode: ${currentPermissionMode}, ` +
`model: ${currentModel ?? 'auto'}, collaborationMode: ${currentCollaborationMode}`
);
const enhancedMode: EnhancedMode = {
permissionMode: messagePermissionMode ?? 'default',
@@ -103,16 +132,13 @@ export async function runCodex(opts: {
const resolveCollaborationMode = (value: unknown): EnhancedMode['collaborationMode'] => {
if (value === null) {
return undefined;
return 'default';
}
if (typeof value !== 'string') {
const parsed = CodexCollaborationModeSchema.safeParse(value);
if (!parsed.success) {
throw new Error('Invalid collaboration mode');
}
const trimmed = value.trim();
if (!trimmed) {
throw new Error('Invalid collaboration mode');
}
return trimmed as EnhancedMode['collaborationMode'];
return parsed.data;
};
session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => {
@@ -126,6 +152,9 @@ export async function runCodex(opts: {
}
if (config.collaborationMode !== undefined) {
if (codexRemoteBackend !== 'app-server') {
throw new Error('Collaboration mode is only supported for Codex app-server remote sessions');
}
currentCollaborationMode = resolveCollaborationMode(config.collaborationMode);
}
@@ -144,6 +173,8 @@ export async function runCodex(opts: {
codexCliOverrides,
startedBy,
permissionMode: currentPermissionMode,
model: currentModel,
collaborationMode: currentCollaborationMode,
resumeSessionId: opts.resumeSessionId,
onModeChange: createModeChangeHandler(session),
onSessionReady: (instance) => {
+16 -1
View File
@@ -4,6 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase';
import type { EnhancedMode, PermissionMode } from './loop';
import type { CodexCliOverrides } from './utils/codexCliOverrides';
import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy';
import type { SessionModel } from '@/api/types';
type LocalLaunchFailure = {
message: string;
@@ -31,6 +32,8 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
codexArgs?: string[];
codexCliOverrides?: CodexCliOverrides;
permissionMode?: PermissionMode;
model?: SessionModel;
collaborationMode?: EnhancedMode['collaborationMode'];
}) {
super({
api: opts.api,
@@ -47,7 +50,9 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
...metadata,
codexSessionId: sessionId
}),
permissionMode: opts.permissionMode
permissionMode: opts.permissionMode,
model: opts.model,
collaborationMode: opts.collaborationMode
});
this.codexArgs = opts.codexArgs;
@@ -55,12 +60,22 @@ export class CodexSession extends AgentSessionBase<EnhancedMode> {
this.startedBy = opts.startedBy;
this.startingMode = opts.startingMode;
this.permissionMode = opts.permissionMode;
this.model = opts.model;
this.collaborationMode = opts.collaborationMode;
}
setPermissionMode = (mode: PermissionMode): void => {
this.permissionMode = mode;
};
setModel = (model: SessionModel): void => {
this.model = model;
};
setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => {
this.collaborationMode = mode;
};
recordLocalLaunchFailure = (message: string, exitReason: LocalLaunchExitReason): void => {
this.localLaunchFailure = { message, exitReason };
};
+70 -11
View File
@@ -7,7 +7,7 @@ describe('appServerConfig', () => {
it('applies CLI overrides when permission mode is default', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'default' },
mode: { permissionMode: 'default', collaborationMode: 'default' },
mcpServers,
cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' }
});
@@ -27,7 +27,7 @@ describe('appServerConfig', () => {
it('ignores CLI overrides when permission mode is not default', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'yolo' },
mode: { permissionMode: 'yolo', collaborationMode: 'default' },
mcpServers,
cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' }
});
@@ -38,7 +38,7 @@ describe('appServerConfig', () => {
it('keeps on-failure approvals for safe-yolo threads', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'safe-yolo' },
mode: { permissionMode: 'safe-yolo', collaborationMode: 'default' },
mcpServers
});
@@ -48,7 +48,7 @@ describe('appServerConfig', () => {
it('concatenates custom developer instructions after base instructions', () => {
const params = buildThreadStartParams({
mode: { permissionMode: 'default' },
mode: { permissionMode: 'default', collaborationMode: 'default' },
mcpServers,
developerInstructions: 'Only respond in Chinese.'
});
@@ -68,14 +68,21 @@ describe('appServerConfig', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'read-only', model: 'o3' }
mode: { permissionMode: 'read-only', model: 'o3', collaborationMode: 'default' }
});
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');
expect(params.collaborationMode).toEqual({
mode: 'default',
settings: {
model: 'o3',
developer_instructions: codexSystemPrompt
}
});
expect(params.model).toBeUndefined();
});
it('puts collaboration mode in turn params with model settings', () => {
@@ -85,43 +92,95 @@ describe('appServerConfig', () => {
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'plan' }
});
expect(params.collaborationMode).toEqual({ mode: 'plan', settings: { model: 'o3' } });
expect(params.collaborationMode).toEqual({
mode: 'plan',
settings: {
model: 'o3',
developer_instructions: codexSystemPrompt
}
});
expect(params.model).toBeUndefined();
});
it('carries custom developer instructions into collaboration mode settings', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'plan' },
developerInstructions: 'Only respond in Chinese.'
});
expect(params.collaborationMode).toEqual({
mode: 'plan',
settings: {
model: 'o3',
developer_instructions: `${codexSystemPrompt}\n\nOnly respond in Chinese.`
}
});
});
it('rejects collaboration mode payloads without a resolved model', () => {
expect(() => buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default', collaborationMode: 'plan' }
})).toThrow("Collaboration mode 'plan' requires a resolved model");
});
it('applies CLI overrides for turns when permission mode is default', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default' },
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' },
cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' }
});
expect(params.approvalPolicy).toBe('never');
expect(params.sandboxPolicy).toEqual({ type: 'dangerFullAccess' });
expect(params.collaborationMode).toEqual({
mode: 'default',
settings: {
model: 'o3',
developer_instructions: codexSystemPrompt
}
});
});
it('ignores CLI overrides for turns when permission mode is not default', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'safe-yolo' },
mode: { permissionMode: 'safe-yolo', model: 'o3', collaborationMode: 'default' },
cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' }
});
expect(params.approvalPolicy).toBe('on-failure');
expect(params.sandboxPolicy).toEqual({ type: 'workspaceWrite' });
expect(params.collaborationMode).toEqual({
mode: 'default',
settings: {
model: 'o3',
developer_instructions: codexSystemPrompt
}
});
});
it('prefers turn overrides', () => {
const params = buildTurnStartParams({
threadId: 'thread-1',
message: 'hello',
mode: { permissionMode: 'default' },
mode: { permissionMode: 'default', collaborationMode: 'default' },
overrides: { approvalPolicy: 'on-request', model: 'gpt-5' }
});
expect(params.approvalPolicy).toBe('on-request');
expect(params.model).toBe('gpt-5');
expect(params.collaborationMode).toEqual({
mode: 'default',
settings: {
model: 'gpt-5',
developer_instructions: codexSystemPrompt
}
});
expect(params.model).toBeUndefined();
});
});
+31 -8
View File
@@ -49,6 +49,20 @@ function buildMcpServerConfig(mcpServers: McpServersConfig): Record<string, unkn
return config;
}
function resolveInstructions(args: {
baseInstructions?: string;
developerInstructions?: string;
}): { baseInstructions: string; developerInstructions: string } {
const baseInstructions = args.baseInstructions ?? codexSystemPrompt;
const developerInstructions = args.developerInstructions
? `${baseInstructions}\n\n${args.developerInstructions}`
: baseInstructions;
return {
baseInstructions,
developerInstructions
};
}
export function buildThreadStartParams(args: {
mode: EnhancedMode;
mcpServers: McpServersConfig;
@@ -64,10 +78,10 @@ export function buildThreadStartParams(args: {
const resolvedSandbox = cliOverrides?.sandbox ?? sandbox;
const config = buildMcpServerConfig(args.mcpServers);
const baseInstructions = args.baseInstructions ?? codexSystemPrompt;
const resolvedDeveloperInstructions = args.developerInstructions
? `${baseInstructions}\n\n${args.developerInstructions}`
: baseInstructions;
const {
baseInstructions,
developerInstructions: resolvedDeveloperInstructions
} = resolveInstructions(args);
const configWithInstructions = {
...config,
developer_instructions: resolvedDeveloperInstructions
@@ -93,6 +107,8 @@ export function buildTurnStartParams(args: {
message: string;
mode?: EnhancedMode;
cliOverrides?: CodexCliOverrides;
baseInstructions?: string;
developerInstructions?: string;
overrides?: {
approvalPolicy?: TurnStartParams['approvalPolicy'];
sandboxPolicy?: TurnStartParams['sandboxPolicy'];
@@ -123,10 +139,17 @@ export function buildTurnStartParams(args: {
const collaborationMode = args.mode?.collaborationMode;
const model = args.overrides?.model ?? args.mode?.model;
if (collaborationMode) {
const settings = model ? { model } : undefined;
params.collaborationMode = settings
? { mode: collaborationMode, settings }
: { mode: collaborationMode };
if (!model) {
throw new Error(`Collaboration mode '${collaborationMode}' requires a resolved model`);
}
const { developerInstructions } = resolveInstructions(args);
params.collaborationMode = {
mode: collaborationMode,
settings: {
model,
developer_instructions: developerInstructions
}
};
} else if (model) {
params.model = model;
}
+4 -4
View File
@@ -8,7 +8,7 @@ describe('buildCodexStartConfig', () => {
it('applies CLI overrides when permission mode is default', () => {
const config = buildCodexStartConfig({
message: 'hello',
mode: { permissionMode: 'default' },
mode: { permissionMode: 'default', collaborationMode: 'default' },
first: true,
mcpServers,
cliOverrides: { sandbox: 'danger-full-access', approvalPolicy: 'never' }
@@ -25,7 +25,7 @@ describe('buildCodexStartConfig', () => {
it('ignores CLI overrides when permission mode is not default', () => {
const config = buildCodexStartConfig({
message: 'hello',
mode: { permissionMode: 'yolo' },
mode: { permissionMode: 'yolo', collaborationMode: 'default' },
first: false,
mcpServers,
cliOverrides: { sandbox: 'read-only', approvalPolicy: 'never' }
@@ -38,7 +38,7 @@ describe('buildCodexStartConfig', () => {
it('keeps on-failure approvals for safe-yolo', () => {
const config = buildCodexStartConfig({
message: 'hello',
mode: { permissionMode: 'safe-yolo' },
mode: { permissionMode: 'safe-yolo', collaborationMode: 'default' },
first: false,
mcpServers
});
@@ -50,7 +50,7 @@ describe('buildCodexStartConfig', () => {
it('passes model when provided', () => {
const config = buildCodexStartConfig({
message: 'hello',
mode: { permissionMode: 'default', model: 'o3' },
mode: { permissionMode: 'default', model: 'o3', collaborationMode: 'default' },
first: false,
mcpServers
});