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') {
+19 -2
View File
@@ -157,13 +157,30 @@ export class RpcGateway {
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: string,
existingSessionId?: string
existingSessionId?: string,
collaborationMode?: CodexCollaborationMode
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
try {
const result = await this.machineRpc(
machineId,
RPC_METHODS.SpawnHappySession,
{ type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier, existingSessionId, sessionId: existingSessionId }
{
type: 'spawn-in-directory',
directory,
agent,
model,
modelReasoningEffort,
yolo,
sessionType,
worktreeName,
resumeSessionId,
effort,
permissionMode,
serviceTier,
existingSessionId,
sessionId: existingSessionId,
collaborationMode
}
)
if (result && typeof result === 'object') {
const obj = result as Record<string, unknown>
+6 -3
View File
@@ -811,7 +811,8 @@ export class SyncEngine {
effort?: string,
permissionMode?: PermissionMode,
serviceTier?: string,
existingSessionId?: string
existingSessionId?: string,
collaborationMode?: CodexCollaborationMode
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
return await this.rpcGateway.spawnSession(
machineId,
@@ -826,7 +827,8 @@ export class SyncEngine {
effort,
permissionMode,
serviceTier,
existingSessionId
existingSessionId,
collaborationMode
)
}
@@ -1300,7 +1302,8 @@ export class SyncEngine {
session.effort ?? undefined,
preferredPermissionMode,
session.serviceTier ?? undefined,
access.sessionId
access.sessionId,
session.collaborationMode ?? undefined
)
if (spawnResult.type !== 'success') {
+41
View File
@@ -857,6 +857,47 @@ describe('Codex Desktop import routes', () => {
}
})
it('applies selected Standard and Plan config before an imported session is resumed', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-config-test-'))
const store = new Store(':memory:')
const codexSessionId = '23232323-2323-4232-8232-232323232323'
process.env.CODEX_HOME = codexHome
try {
createTranscript(codexHome, codexSessionId, '/home/user/workspace/project')
const engine = createImportSyncEngine(store, [
createMachine('machine-1', ['/home/user/workspace'])
])
const applied: Array<{ sessionId: string; config: unknown }> = []
;(engine as unknown as { applySessionConfig: (sessionId: string, config: unknown) => Promise<void> }).applySessionConfig = async (sessionId, config) => {
applied.push({ sessionId, config })
}
const result = await importSelectedCodexSessions({
codexSessionIds: [codexSessionId],
store,
namespace: 'default',
getSyncEngine: () => engine,
serviceTier: 'standard',
collaborationMode: 'plan'
})
expect(result.success).toBe(true)
const importedSessionId = result.success ? result.hapiSessionIds?.[0] : undefined
expect(importedSessionId).toBeDefined()
if (!importedSessionId) {
throw new Error('Imported session id missing')
}
expect(applied).toEqual([{
sessionId: importedSessionId,
config: { serviceTier: 'standard', collaborationMode: 'plan' }
}])
} finally {
store.close()
rmSync(codexHome, { recursive: true, force: true })
}
})
it('binds imported transcripts to the unique online machine that owns the cwd', async () => {
const codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-'))
const store = new Store(':memory:')
+35 -1
View File
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
import { dirname, isAbsolute, join, resolve } from 'node:path'
import { homedir, hostname, platform } from 'node:os'
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
import type { CodexCollaborationMode } from '@hapi/protocol/types'
import { Hono } from 'hono'
import type { Machine, SyncEngine } from '../../sync/syncEngine'
import type { Store, StoredMessage } from '../../store'
@@ -131,6 +132,8 @@ type SyncSessionRequestParseResult = {
machineId?: string | null
model?: string | null
modelReasoningEffort?: string | null
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
yolo?: boolean
error?: string
}
@@ -1701,7 +1704,7 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
return { sessionIds: [] }
}
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; yolo?: unknown }
const bodyRecord = body as { sessionIds?: unknown; cwd?: unknown; machineId?: unknown; model?: unknown; modelReasoningEffort?: unknown; serviceTier?: unknown; collaborationMode?: unknown; yolo?: unknown }
const rawSessionIds = bodyRecord.sessionIds
if (!Array.isArray(rawSessionIds)) {
return { sessionIds: [], error: 'Invalid sessionIds' }
@@ -1720,6 +1723,14 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort')
const hasServiceTier = Object.prototype.hasOwnProperty.call(bodyRecord, 'serviceTier')
const hasCollaborationMode = Object.prototype.hasOwnProperty.call(bodyRecord, 'collaborationMode')
if (hasServiceTier && bodyRecord.serviceTier !== null && bodyRecord.serviceTier !== 'fast' && bodyRecord.serviceTier !== 'standard') {
return { sessionIds: [], error: 'Invalid serviceTier' }
}
if (hasCollaborationMode && bodyRecord.collaborationMode !== 'default' && bodyRecord.collaborationMode !== 'plan') {
return { sessionIds: [], error: 'Invalid collaborationMode' }
}
// 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。
return {
@@ -1728,6 +1739,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null,
model: hasModel ? (typeof bodyRecord.model === 'string' && bodyRecord.model.trim() ? bodyRecord.model.trim() : null) : undefined,
modelReasoningEffort: hasModelReasoningEffort ? (typeof bodyRecord.modelReasoningEffort === 'string' && bodyRecord.modelReasoningEffort.trim() ? bodyRecord.modelReasoningEffort.trim() : null) : undefined,
serviceTier: hasServiceTier ? bodyRecord.serviceTier as 'fast' | 'standard' | null : undefined,
collaborationMode: hasCollaborationMode ? bodyRecord.collaborationMode as CodexCollaborationMode : undefined,
yolo: bodyRecord.yolo === true
}
}
@@ -1952,6 +1965,8 @@ export async function importSelectedCodexSessions(options: {
localSessions?: RemoteCodexSession[]
model?: string | null
modelReasoningEffort?: string | null
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
yolo?: boolean
machineId?: string | null
}): Promise<ScriptLaunchResponse> {
@@ -1976,6 +1991,23 @@ export async function importSelectedCodexSessions(options: {
})
results.push(result)
if (result.success && (options.serviceTier !== undefined || options.collaborationMode !== undefined)) {
const importedSessionId = result.hapiSessionIds?.[0]
const engine = options.getSyncEngine?.() ?? null
if (!importedSessionId || !engine) {
return createImportErrorResponse(codexSessionIds, 'Imported session config could not be applied before resume')
}
try {
await engine.applySessionConfig(importedSessionId, {
...(options.serviceTier !== undefined ? { serviceTier: options.serviceTier } : {}),
...(options.collaborationMode !== undefined ? { collaborationMode: options.collaborationMode } : {})
})
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
return createImportErrorResponse(codexSessionIds, `Failed to apply imported session config: ${message}`)
}
}
if (!result.success) {
return {
...result,
@@ -2110,6 +2142,8 @@ export function createCodexDesktopRoutes(options: {
machineId: remote.machineId ?? null,
model: parsed.model,
modelReasoningEffort: parsed.modelReasoningEffort,
serviceTier: parsed.serviceTier,
collaborationMode: parsed.collaborationMode,
yolo: parsed.yolo
})
return c.json({
+4 -1
View File
@@ -51,7 +51,10 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
parsed.data.worktreeName,
undefined,
parsed.data.effort,
parsed.data.permissionMode
parsed.data.permissionMode,
parsed.data.serviceTier,
undefined,
parsed.data.collaborationMode
)
return c.json(result)
})
+3 -1
View File
@@ -326,7 +326,9 @@ export const SpawnSessionRequestSchema = z.object({
yolo: z.boolean().optional(),
permissionMode: PermissionModeSchema.optional(),
sessionType: z.enum(['simple', 'worktree']).optional(),
worktreeName: z.string().optional()
worktreeName: z.string().optional(),
serviceTier: z.enum(['fast', 'standard']).optional(),
collaborationMode: CodexCollaborationModeSchema.optional()
})
export type SpawnSessionRequest = z.infer<typeof SpawnSessionRequestSchema>
+6 -2
View File
@@ -629,7 +629,9 @@ export class ApiClient {
sessionType?: 'simple' | 'worktree',
worktreeName?: string,
effort?: string,
permissionMode?: PermissionMode
permissionMode?: PermissionMode,
serviceTier?: 'fast' | 'standard',
collaborationMode?: 'default' | 'plan'
): Promise<SpawnResponse> {
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
method: 'POST',
@@ -642,7 +644,9 @@ export class ApiClient {
sessionType,
worktreeName,
effort,
permissionMode
permissionMode,
serviceTier,
collaborationMode
})
})
}
@@ -0,0 +1,37 @@
import { getCodexCollaborationModeOptions, type CodexCollaborationMode } from '@hapi/protocol'
import { useTranslation } from '@/lib/use-translation'
import type { AgentType } from './types'
export function CollaborationModeSelector(props: {
agent: AgentType
value: CodexCollaborationMode
isDisabled: boolean
onChange: (value: CodexCollaborationMode) => void
}) {
const { t } = useTranslation()
if (props.agent !== 'codex') {
return null
}
return (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.collaborationMode')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
value={props.value}
onChange={(e) => props.onChange(e.target.value as CodexCollaborationMode)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
{getCodexCollaborationModeOptions().map((option) => (
<option key={option.mode} value={option.mode}>
{option.label}
</option>
))}
</select>
</div>
)
}
@@ -0,0 +1,35 @@
import { useTranslation } from '@/lib/use-translation'
import type { NewSessionServiceTier } from './types'
export type { NewSessionServiceTier }
export function FastModeSelector(props: {
visible: boolean
value: NewSessionServiceTier
isDisabled: boolean
onChange: (value: NewSessionServiceTier) => void
}) {
const { t } = useTranslation()
if (!props.visible) {
return null
}
return (
<div className="flex flex-col gap-1.5 px-3 py-3">
<label className="text-xs font-medium text-[var(--app-hint)]">
{t('newSession.fastMode')}{' '}
<span className="font-normal">({t('newSession.model.optional')})</span>
</label>
<select
value={props.value}
onChange={(e) => props.onChange(e.target.value as NewSessionServiceTier)}
disabled={props.isDisabled}
className="w-full px-3 py-2 text-sm rounded-lg border border-[var(--app-divider)] bg-[var(--app-bg)] text-[var(--app-text)] focus:outline-none focus:ring-2 focus:ring-[var(--app-link)] disabled:opacity-50"
>
<option value="standard">{t('misc.fastModeStandard')}</option>
<option value="fast">{t('misc.fastModeFast')}</option>
</select>
</div>
)
}
@@ -314,6 +314,8 @@ describe('NewSession launch preferences', () => {
machineId: 'machine-1',
effort: 'auto',
modelReasoningEffort: 'max',
serviceTier: 'standard',
collaborationMode: 'default',
yoloMode: false,
grokPermissionMode: 'default',
sessionType: 'simple',
+57 -3
View File
@@ -1,7 +1,8 @@
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
import type { ApiClient } from '@/api/client'
import type { CodexLocalSessionSummary, Machine } from '@/types/api'
import type { GrokPermissionMode } from '@hapi/protocol'
import type { CodexCollaborationMode, GrokPermissionMode } from '@hapi/protocol'
import { codexModelAdvertisesFastTier } from '@/components/AssistantChat/codexFastMode'
import { usePlatform } from '@/hooks/usePlatform'
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
@@ -33,11 +34,13 @@ import {
saveNewSessionFormDraft,
shouldRestoreNewSessionFormDraft
} from './newSessionFormDraft'
import type { AgentType, LaunchEffort, CodexReasoningEffort, SessionType } from './types'
import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types'
import { ActionButtons } from './ActionButtons'
import { AgentSelector } from './AgentSelector'
import { CollaborationModeSelector } from './CollaborationModeSelector'
import { DirectorySection } from './DirectorySection'
import { GrokPermissionModeSelector } from './GrokPermissionModeSelector'
import { FastModeSelector } from './FastModeSelector'
import { MachineSelector } from './MachineSelector'
import { ModelSelector } from './ModelSelector'
import { OpencodeModelSelector } from './OpencodeModelSelector'
@@ -129,6 +132,8 @@ export function NewSession(props: {
const [effort, setEffort] = useState<LaunchEffort>('auto')
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
const [opencodeSelectedModel, setOpencodeSelectedModel] = useState<string | null>(null)
const [serviceTier, setServiceTier] = useState<NewSessionServiceTier>('standard')
const [collaborationMode, setCollaborationMode] = useState<CodexCollaborationMode>('default')
const [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
const [grokPermissionMode, setGrokPermissionMode] = useState<GrokPermissionMode>('default')
const [sessionType, setSessionType] = useState<SessionType>('simple')
@@ -159,6 +164,8 @@ export function NewSession(props: {
setEffort('auto')
setModelReasoningEffort('default')
setGrokPermissionMode('default')
setServiceTier('standard')
setCollaborationMode('default')
if (agent !== 'cursor') {
setModel('auto')
setCursorSelectedBase('auto')
@@ -224,6 +231,8 @@ export function NewSession(props: {
setOpencodeSelectedModel(
draft.agent === 'opencode' && draft.model !== 'auto' ? draft.model : null
)
setServiceTier(draft.serviceTier)
setCollaborationMode(draft.collaborationMode)
setYoloMode(draft.yoloMode)
setGrokPermissionMode(draft.grokPermissionMode)
setSessionType(draft.sessionType)
@@ -313,6 +322,21 @@ export function NewSession(props: {
setModel('auto')
}
}, [agent, codexModelsState.error, codexModelsState.isLoading, codexModelsState.models, model])
const showCodexFastMode = agent === 'codex'
&& !codexModelsState.error
&& codexModelAdvertisesFastTier(model === 'auto' ? null : model, codexModelsState.models)
useEffect(() => {
// Wait for the Codex model catalog to settle before clearing Fast;
// otherwise Browse → remount restores serviceTier: 'fast' and this
// effect would wipe it while models are still loading.
if (agent === 'codex' && codexModelsState.isLoading) {
return
}
if (!showCodexFastMode && serviceTier !== 'standard') {
setServiceTier('standard')
}
}, [agent, codexModelsState.isLoading, showCodexFastMode, serviceTier])
const cursorModelsState = useCursorModelsForMachine({
api: props.api,
machineId,
@@ -767,6 +791,8 @@ export function NewSession(props: {
machineId,
effort,
modelReasoningEffort,
serviceTier,
collaborationMode,
yoloMode,
grokPermissionMode,
sessionType,
@@ -782,6 +808,8 @@ export function NewSession(props: {
machineId,
effort,
modelReasoningEffort,
serviceTier,
collaborationMode,
yoloMode,
grokPermissionMode,
sessionType,
@@ -894,6 +922,12 @@ export function NewSession(props: {
effort,
modelReasoningEffort
}
const resolvedServiceTier = agent === 'codex' && showCodexFastMode
? serviceTier
: undefined
const resolvedCollaborationMode = agent === 'codex' && collaborationMode !== 'default'
? collaborationMode
: undefined
if (agent === 'codex' && selectedCodexImportSession) {
setIsImportingCodexSession(true)
@@ -903,6 +937,8 @@ export function NewSession(props: {
machineId: codexImportMachineId ?? machineId,
model: resolvedModel ?? null,
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
serviceTier: resolvedServiceTier,
collaborationMode: resolvedCollaborationMode ?? 'default',
yolo: yoloMode
})
if (result.success) {
@@ -941,7 +977,9 @@ export function NewSession(props: {
yolo: agent === 'grok' ? undefined : yoloMode,
permissionMode: agent === 'grok' ? grokPermissionMode : undefined,
sessionType,
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined,
serviceTier: resolvedServiceTier,
collaborationMode: resolvedCollaborationMode
})
if (result.type === 'success') {
@@ -984,12 +1022,16 @@ export function NewSession(props: {
deferredDirectoryExists === undefined
|| (deferredDirectoryExists === true && opencodeModelsState.isLoading)
))
const fastModeSelectionPending = agent === 'codex'
&& serviceTier === 'fast'
&& codexModelsState.isLoading
const canCreate = Boolean(
machineId
&& trimmedDirectory
&& !isFormDisabled
&& !missingWorktreeDirectory
&& !isLaunchPreferenceValidationPending
&& !fastModeSelectionPending
)
return (
@@ -1148,6 +1190,18 @@ export function NewSession(props: {
isDisabled={isFormDisabled}
onChange={setGrokPermissionMode}
/>
<CollaborationModeSelector
agent={agent}
value={collaborationMode}
isDisabled={isFormDisabled}
onChange={setCollaborationMode}
/>
<FastModeSelector
visible={showCodexFastMode}
value={serviceTier}
isDisabled={isFormDisabled}
onChange={setServiceTier}
/>
{agent !== 'grok' ? (
<YoloToggle
yoloMode={yoloMode}
@@ -20,6 +20,8 @@ describe('newSessionFormDraft', () => {
machineId: 'machine-1',
effort: 'auto',
modelReasoningEffort: 'default',
serviceTier: 'standard',
collaborationMode: 'default',
yoloMode: false,
grokPermissionMode: 'default',
sessionType: 'simple',
@@ -33,6 +35,8 @@ describe('newSessionFormDraft', () => {
machineId: 'machine-1',
effort: 'auto',
modelReasoningEffort: 'default',
serviceTier: 'standard',
collaborationMode: 'default',
yoloMode: false,
grokPermissionMode: 'default',
sessionType: 'simple',
@@ -59,6 +63,8 @@ describe('newSessionFormDraft', () => {
machineId: null,
effort: 'auto',
modelReasoningEffort: 'default',
serviceTier: 'standard',
collaborationMode: 'default',
yoloMode: false,
grokPermissionMode: 'default',
sessionType: 'simple',
@@ -76,6 +82,8 @@ describe('newSessionFormDraft', () => {
machineId: 'machine-a',
effort: 'auto',
modelReasoningEffort: 'default',
serviceTier: 'fast',
collaborationMode: 'plan',
yoloMode: false,
grokPermissionMode: 'default',
sessionType: 'simple',
@@ -83,6 +91,8 @@ describe('newSessionFormDraft', () => {
})
const draft = loadNewSessionFormDraft()!
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
expect(draft.serviceTier).toBe('fast')
expect(draft.collaborationMode).toBe('plan')
})
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
@@ -93,6 +103,8 @@ describe('newSessionFormDraft', () => {
machineId: 'machine-1',
effort: 'high',
modelReasoningEffort: 'high',
serviceTier: 'fast',
collaborationMode: 'plan',
yoloMode: true,
grokPermissionMode: 'default',
sessionType: 'simple',
@@ -106,6 +118,8 @@ describe('newSessionFormDraft', () => {
expect(loaded.cursorSelectedBase).toBe('auto')
expect(loaded.effort).toBe('auto')
expect(loaded.modelReasoningEffort).toBe('default')
expect(loaded.serviceTier).toBe('standard')
expect(loaded.collaborationMode).toBe('default')
// agent-independent fields preserved
expect(loaded.yoloMode).toBe(true)
expect(loaded.machineId).toBe('machine-1')
@@ -1,9 +1,10 @@
import {
CREATABLE_AGENT_FLAVORS,
GROK_PERMISSION_MODES,
type CodexCollaborationMode,
type GrokPermissionMode
} from '@hapi/protocol'
import type { AgentType, LaunchEffort, CodexReasoningEffort, SessionType } from './types'
import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types'
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
@@ -14,6 +15,8 @@ export type NewSessionFormDraft = {
machineId: string | null
effort: LaunchEffort
modelReasoningEffort: CodexReasoningEffort
serviceTier: NewSessionServiceTier
collaborationMode: CodexCollaborationMode
yoloMode: boolean
grokPermissionMode: GrokPermissionMode
sessionType: SessionType
@@ -57,6 +60,8 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
modelReasoningEffort: agentPreserved
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
: 'default',
serviceTier: agentPreserved && parsed.serviceTier === 'fast' ? 'fast' : 'standard',
collaborationMode: agentPreserved && parsed.collaborationMode === 'plan' ? 'plan' : 'default',
yoloMode: Boolean(parsed.yoloMode),
grokPermissionMode: agentPreserved
&& GROK_PERMISSION_MODES.includes(parsed.grokPermissionMode as GrokPermissionMode)
+1
View File
@@ -15,6 +15,7 @@ export type CodexReasoningEffort = string
// Grok reports effort values dynamically through ACP, while Claude uses the
// fixed ClaudeEffortLevel catalog.
export type LaunchEffort = string
export type NewSessionServiceTier = 'standard' | 'fast'
function modelPresetOptions<TModel extends string>(
presets: readonly TModel[],
+6 -2
View File
@@ -1,5 +1,5 @@
import { useMutation, useQueryClient } from '@tanstack/react-query'
import type { AgentFlavor, PermissionMode } from '@hapi/protocol'
import type { AgentFlavor, CodexCollaborationMode, PermissionMode } from '@hapi/protocol'
import type { ApiClient } from '@/api/client'
import type { SpawnResponse } from '@/types/api'
import { queryKeys } from '@/lib/query-keys'
@@ -15,6 +15,8 @@ type SpawnInput = {
permissionMode?: PermissionMode
sessionType?: 'simple' | 'worktree'
worktreeName?: string
serviceTier?: 'fast' | 'standard'
collaborationMode?: CodexCollaborationMode
}
export function useSpawnSession(api: ApiClient | null): {
@@ -39,7 +41,9 @@ export function useSpawnSession(api: ApiClient | null): {
input.sessionType,
input.worktreeName,
input.effort,
input.permissionMode
input.permissionMode,
input.serviceTier,
input.collaborationMode
)
},
onSuccess: () => {
+2
View File
@@ -272,6 +272,8 @@ export default {
'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory',
'newSession.opencodeModel.default': 'Default',
'newSession.reasoningEffort': 'Reasoning effort',
'newSession.collaborationMode': 'Collaboration mode',
'newSession.fastMode': 'Fast mode',
'newSession.yolo': 'YOLO mode',
'newSession.yolo.title': 'Bypass approvals and sandbox',
'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.',
+2
View File
@@ -276,6 +276,8 @@ export default {
'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型',
'newSession.opencodeModel.default': '默认',
'newSession.reasoningEffort': '推理强度',
'newSession.collaborationMode': '协作模式',
'newSession.fastMode': '快速模式',
'newSession.yolo': 'YOLO 模式',
'newSession.yolo.title': '跳过审批和沙箱',
'newSession.yolo.desc': '启动时使用危险的代理标志。',
+3
View File
@@ -1,4 +1,5 @@
import type {
CodexCollaborationMode,
DecryptedMessage as ProtocolDecryptedMessage,
Machine,
RunnerState,
@@ -219,6 +220,8 @@ export type CodexDesktopSyncRequest = {
machineId?: string | null
model?: string | null
modelReasoningEffort?: string | null
serviceTier?: string | null
collaborationMode?: CodexCollaborationMode
yolo?: boolean
}