mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-05 06:24:37 +00:00
Add support for codex plan mode
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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) => {
|
||||
|
||||
@@ -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 };
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user