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
+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)
})