fix(web): expose Codex Fast and Plan on Create Session (#1017)

* fix(web): expose Codex Fast and Plan on Create Session

Wire serviceTier and collaborationMode through spawn so Create can set
the same Codex options chat Settings already supports (#1015).

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): forward collaborationMode through machine spawn RPC

Create Session Plan was accepted by the hub but dropped in apiMachine
before buildCliArgs; also preserve collaborationMode on resume spawn.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(cli): correct stopSession mock type in spawn RPC test

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): keep Fast mode across Create draft restore while models load

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(web): preserve pending Fast selection

* fix: apply Fast and Plan to imported Codex sessions

* test: narrow imported Codex session id

* fix: forward explicit Standard service tier

* fix: integrate create-session controls with current main

* test: close Codex RPC suite

* fix: preserve existing session spawn field

* fix(web): integrate Codex controls with current New Session form

* fix(web): reconcile draft types and submit state

* fix(hub): integrate spawn arguments with current resume flow

* test(cli): isolate spawn RPC suite

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
SSU-WEI HUANG
2026-07-28 12:13:29 +08:00
committed by GitHub
co-authored by Cursor
parent 5396d5f097
commit 07db10f86d
25 changed files with 395 additions and 17 deletions
+60
View File
@@ -421,6 +421,66 @@ describe('ApiMachineClient Codex transcript handlers', () => {
client.shutdown()
}
})
})
describe('ApiMachineClient SpawnHappySession handler', () => {
let workspaceRoot: string
beforeEach(() => {
ioMock.mockReset()
workspaceRoot = mkdtempSync(join(tmpdir(), 'hapi-machine-spawn-'))
})
afterEach(() => {
rmSync(workspaceRoot, { recursive: true, force: true })
})
async function callSpawnHappySession(
client: ApiMachineClient,
machineId: string,
params: Record<string, unknown>
): Promise<unknown> {
const manager = (client as unknown as {
rpcHandlerManager: { handleRequest: (req: { method: string; params: string }) => Promise<string> }
}).rpcHandlerManager
const raw = await manager.handleRequest({
method: `${machineId}:spawn-happy-session`,
params: JSON.stringify(params)
})
return JSON.parse(raw) as unknown
}
it('forwards collaborationMode and serviceTier to spawnSession', async () => {
const machine = makeMachine('machine-spawn-1')
const client = new ApiMachineClient('cli-token', machine, [workspaceRoot])
const spawnSession = vi.fn(async () => ({ type: 'success' as const, sessionId: 'session-1' }))
client.setRPCHandlers({
spawnSession,
stopSession: vi.fn(() => true),
requestShutdown: vi.fn()
})
try {
const result = await callSpawnHappySession(client, machine.id, {
directory: workspaceRoot,
agent: 'codex',
serviceTier: 'fast',
collaborationMode: 'plan'
})
expect(result).toEqual({ type: 'success', sessionId: 'session-1' })
expect(spawnSession).toHaveBeenCalledWith(expect.objectContaining({
directory: workspaceRoot,
agent: 'codex',
serviceTier: 'fast',
collaborationMode: 'plan'
}))
} finally {
client.shutdown()
}
})
})
describe('ApiMachineClient keepAlive lifecycle', () => {
+2 -1
View File
@@ -357,7 +357,7 @@ export class ApiMachineClient {
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, token, sessionType, worktreeName } = params || {}
const { directory, sessionId, existingSessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, serviceTier, collaborationMode, token, sessionType, worktreeName } = params || {}
if (!directory) {
throw new Error('Directory is required')
@@ -382,6 +382,7 @@ export class ApiMachineClient {
yolo,
permissionMode,
serviceTier,
collaborationMode,
token,
sessionType,
worktreeName
+9
View File
@@ -112,6 +112,15 @@ describe('codexCommand', () => {
})
})
it('forwards a valid --collaboration-mode to runCodex', async () => {
await codexCommand.run(createCommandContext(['--started-by', 'runner', '--collaboration-mode', 'plan']))
expect(runCodexMock).toHaveBeenCalledWith({
startedBy: 'runner',
collaborationMode: 'plan'
})
})
it('rejects an unsupported --service-tier value', async () => {
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
+16
View File
@@ -5,6 +5,7 @@ import { maybeAutoStartServer } from '@/utils/autoStartServer'
import type { CommandDefinition } from './types'
import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
import type { CodexPermissionMode } from '@hapi/protocol/types'
import { CodexCollaborationModeSchema } from '@hapi/protocol/schemas'
import type { ReasoningEffort } from '@/codex/appServerTypes'
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
@@ -19,6 +20,14 @@ function parseServiceTier(value: string): 'fast' | 'standard' {
throw new Error('Invalid --service-tier value')
}
function parseCollaborationMode(value: string): 'default' | 'plan' {
const parsed = CodexCollaborationModeSchema.safeParse(value.trim().toLowerCase())
if (!parsed.success) {
throw new Error('Invalid --collaboration-mode value')
}
return parsed.data
}
export const codexCommand: CommandDefinition = {
name: 'codex',
requiresRuntimeAssets: true,
@@ -35,6 +44,7 @@ export const codexCommand: CommandDefinition = {
model?: string
modelReasoningEffort?: ReasoningEffort
serviceTier?: string
collaborationMode?: 'default' | 'plan'
} = {}
const unknownArgs: string[] = []
let hasExplicitPermissionMode = false
@@ -88,6 +98,12 @@ export const codexCommand: CommandDefinition = {
throw new Error('Missing --service-tier value')
}
options.serviceTier = parseServiceTier(tier)
} else if (arg === '--collaboration-mode') {
const mode = commandArgs[++i]
if (!mode) {
throw new Error('Missing --collaboration-mode value')
}
options.collaborationMode = parseCollaborationMode(mode)
} else {
unknownArgs.push(arg)
}
+1
View File
@@ -14,6 +14,7 @@ export interface SpawnSessionOptions {
yolo?: boolean
permissionMode?: string
serviceTier?: string
collaborationMode?: 'default' | 'plan'
token?: string
sessionType?: 'simple' | 'worktree'
worktreeName?: string
+25
View File
@@ -84,6 +84,23 @@ describe('buildCliArgs', () => {
expect(args).toContain('fast')
})
it('passes --collaboration-mode through for codex Plan mode', () => {
const args = buildCliArgs('codex', {
directory: '/tmp',
collaborationMode: 'plan',
})
expect(args).toContain('--collaboration-mode')
expect(args).toContain('plan')
})
it('omits --collaboration-mode for default collaboration mode', () => {
const args = buildCliArgs('codex', {
directory: '/tmp',
collaborationMode: 'default',
})
expect(args).not.toContain('--collaboration-mode')
})
it('does not pass --service-tier for non-codex agents', () => {
const args = buildCliArgs('claude', {
directory: '/tmp',
@@ -143,6 +160,14 @@ describe('buildCliArgs', () => {
expect(args).toContain('cursor-csid-1')
})
it('does not pass --collaboration-mode for non-codex agents', () => {
const args = buildCliArgs('claude', {
directory: '/tmp',
collaborationMode: 'plan',
})
expect(args).not.toContain('--collaboration-mode')
})
it('validates all known permission modes', () => {
for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) {
const args = buildCliArgs('claude', {
+3
View File
@@ -1136,6 +1136,9 @@ export function buildCliArgs(
if (options.serviceTier && agent === 'codex') {
args.push('--service-tier', options.serviceTier);
}
if (options.collaborationMode && options.collaborationMode !== 'default' && agent === 'codex') {
args.push('--collaboration-mode', options.collaborationMode);
}
// Pi RPC mode has no permission switching; never pass these flags to it
// (the Pi parser rejects --permission-mode and ignores --yolo).
if (agent !== 'pi') {