mirror of
https://github.com/wu736139669/hapi.git
synced 2026-08-07 06:52:28 +00:00
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:
@@ -421,6 +421,66 @@ describe('ApiMachineClient Codex transcript handlers', () => {
|
|||||||
client.shutdown()
|
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', () => {
|
describe('ApiMachineClient keepAlive lifecycle', () => {
|
||||||
|
|||||||
@@ -357,7 +357,7 @@ export class ApiMachineClient {
|
|||||||
|
|
||||||
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void {
|
||||||
this.rpcHandlerManager.registerHandler(RPC_METHODS.SpawnHappySession, async (params: any) => {
|
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) {
|
if (!directory) {
|
||||||
throw new Error('Directory is required')
|
throw new Error('Directory is required')
|
||||||
@@ -382,6 +382,7 @@ export class ApiMachineClient {
|
|||||||
yolo,
|
yolo,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
serviceTier,
|
serviceTier,
|
||||||
|
collaborationMode,
|
||||||
token,
|
token,
|
||||||
sessionType,
|
sessionType,
|
||||||
worktreeName
|
worktreeName
|
||||||
|
|||||||
@@ -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 () => {
|
it('rejects an unsupported --service-tier value', async () => {
|
||||||
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||||
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
const exitSpy = vi.spyOn(process, 'exit').mockImplementation(((code?: number) => {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { maybeAutoStartServer } from '@/utils/autoStartServer'
|
|||||||
import type { CommandDefinition } from './types'
|
import type { CommandDefinition } from './types'
|
||||||
import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
|
import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes'
|
||||||
import type { CodexPermissionMode } from '@hapi/protocol/types'
|
import type { CodexPermissionMode } from '@hapi/protocol/types'
|
||||||
|
import { CodexCollaborationModeSchema } from '@hapi/protocol/schemas'
|
||||||
import type { ReasoningEffort } from '@/codex/appServerTypes'
|
import type { ReasoningEffort } from '@/codex/appServerTypes'
|
||||||
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
import { assertCodexLocalSupported } from '@/codex/utils/codexVersion'
|
||||||
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
|
import { parseReasoningEffortValue } from '@/codex/utils/reasoningEffort'
|
||||||
@@ -19,6 +20,14 @@ function parseServiceTier(value: string): 'fast' | 'standard' {
|
|||||||
throw new Error('Invalid --service-tier value')
|
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 = {
|
export const codexCommand: CommandDefinition = {
|
||||||
name: 'codex',
|
name: 'codex',
|
||||||
requiresRuntimeAssets: true,
|
requiresRuntimeAssets: true,
|
||||||
@@ -35,6 +44,7 @@ export const codexCommand: CommandDefinition = {
|
|||||||
model?: string
|
model?: string
|
||||||
modelReasoningEffort?: ReasoningEffort
|
modelReasoningEffort?: ReasoningEffort
|
||||||
serviceTier?: string
|
serviceTier?: string
|
||||||
|
collaborationMode?: 'default' | 'plan'
|
||||||
} = {}
|
} = {}
|
||||||
const unknownArgs: string[] = []
|
const unknownArgs: string[] = []
|
||||||
let hasExplicitPermissionMode = false
|
let hasExplicitPermissionMode = false
|
||||||
@@ -88,6 +98,12 @@ export const codexCommand: CommandDefinition = {
|
|||||||
throw new Error('Missing --service-tier value')
|
throw new Error('Missing --service-tier value')
|
||||||
}
|
}
|
||||||
options.serviceTier = parseServiceTier(tier)
|
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 {
|
} else {
|
||||||
unknownArgs.push(arg)
|
unknownArgs.push(arg)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ export interface SpawnSessionOptions {
|
|||||||
yolo?: boolean
|
yolo?: boolean
|
||||||
permissionMode?: string
|
permissionMode?: string
|
||||||
serviceTier?: string
|
serviceTier?: string
|
||||||
|
collaborationMode?: 'default' | 'plan'
|
||||||
token?: string
|
token?: string
|
||||||
sessionType?: 'simple' | 'worktree'
|
sessionType?: 'simple' | 'worktree'
|
||||||
worktreeName?: string
|
worktreeName?: string
|
||||||
|
|||||||
@@ -84,6 +84,23 @@ describe('buildCliArgs', () => {
|
|||||||
expect(args).toContain('fast')
|
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', () => {
|
it('does not pass --service-tier for non-codex agents', () => {
|
||||||
const args = buildCliArgs('claude', {
|
const args = buildCliArgs('claude', {
|
||||||
directory: '/tmp',
|
directory: '/tmp',
|
||||||
@@ -143,6 +160,14 @@ describe('buildCliArgs', () => {
|
|||||||
expect(args).toContain('cursor-csid-1')
|
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', () => {
|
it('validates all known permission modes', () => {
|
||||||
for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) {
|
for (const mode of ['default', 'acceptEdits', 'auto', 'bypassPermissions', 'plan', 'ask', 'debug', 'autoReview', 'read-only', 'safe-yolo', 'yolo']) {
|
||||||
const args = buildCliArgs('claude', {
|
const args = buildCliArgs('claude', {
|
||||||
|
|||||||
@@ -1136,6 +1136,9 @@ export function buildCliArgs(
|
|||||||
if (options.serviceTier && agent === 'codex') {
|
if (options.serviceTier && agent === 'codex') {
|
||||||
args.push('--service-tier', options.serviceTier);
|
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
|
// Pi RPC mode has no permission switching; never pass these flags to it
|
||||||
// (the Pi parser rejects --permission-mode and ignores --yolo).
|
// (the Pi parser rejects --permission-mode and ignores --yolo).
|
||||||
if (agent !== 'pi') {
|
if (agent !== 'pi') {
|
||||||
|
|||||||
@@ -157,13 +157,30 @@ export class RpcGateway {
|
|||||||
effort?: string,
|
effort?: string,
|
||||||
permissionMode?: PermissionMode,
|
permissionMode?: PermissionMode,
|
||||||
serviceTier?: string,
|
serviceTier?: string,
|
||||||
existingSessionId?: string
|
existingSessionId?: string,
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||||
try {
|
try {
|
||||||
const result = await this.machineRpc(
|
const result = await this.machineRpc(
|
||||||
machineId,
|
machineId,
|
||||||
RPC_METHODS.SpawnHappySession,
|
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') {
|
if (result && typeof result === 'object') {
|
||||||
const obj = result as Record<string, unknown>
|
const obj = result as Record<string, unknown>
|
||||||
|
|||||||
@@ -811,7 +811,8 @@ export class SyncEngine {
|
|||||||
effort?: string,
|
effort?: string,
|
||||||
permissionMode?: PermissionMode,
|
permissionMode?: PermissionMode,
|
||||||
serviceTier?: string,
|
serviceTier?: string,
|
||||||
existingSessionId?: string
|
existingSessionId?: string,
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> {
|
||||||
return await this.rpcGateway.spawnSession(
|
return await this.rpcGateway.spawnSession(
|
||||||
machineId,
|
machineId,
|
||||||
@@ -826,7 +827,8 @@ export class SyncEngine {
|
|||||||
effort,
|
effort,
|
||||||
permissionMode,
|
permissionMode,
|
||||||
serviceTier,
|
serviceTier,
|
||||||
existingSessionId
|
existingSessionId,
|
||||||
|
collaborationMode
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1300,7 +1302,8 @@ export class SyncEngine {
|
|||||||
session.effort ?? undefined,
|
session.effort ?? undefined,
|
||||||
preferredPermissionMode,
|
preferredPermissionMode,
|
||||||
session.serviceTier ?? undefined,
|
session.serviceTier ?? undefined,
|
||||||
access.sessionId
|
access.sessionId,
|
||||||
|
session.collaborationMode ?? undefined
|
||||||
)
|
)
|
||||||
|
|
||||||
if (spawnResult.type !== 'success') {
|
if (spawnResult.type !== 'success') {
|
||||||
|
|||||||
@@ -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 () => {
|
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 codexHome = mkdtempSync(join(tmpdir(), 'hapi-codex-home-machine-test-'))
|
||||||
const store = new Store(':memory:')
|
const store = new Store(':memory:')
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto'
|
|||||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||||
import { homedir, hostname, platform } from 'node:os'
|
import { homedir, hostname, platform } from 'node:os'
|
||||||
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
|
import { AGENT_MESSAGE_PAYLOAD_TYPE } from '@hapi/protocol'
|
||||||
|
import type { CodexCollaborationMode } from '@hapi/protocol/types'
|
||||||
import { Hono } from 'hono'
|
import { Hono } from 'hono'
|
||||||
import type { Machine, SyncEngine } from '../../sync/syncEngine'
|
import type { Machine, SyncEngine } from '../../sync/syncEngine'
|
||||||
import type { Store, StoredMessage } from '../../store'
|
import type { Store, StoredMessage } from '../../store'
|
||||||
@@ -131,6 +132,8 @@ type SyncSessionRequestParseResult = {
|
|||||||
machineId?: string | null
|
machineId?: string | null
|
||||||
model?: string | null
|
model?: string | null
|
||||||
modelReasoningEffort?: string | null
|
modelReasoningEffort?: string | null
|
||||||
|
serviceTier?: string | null
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
yolo?: boolean
|
yolo?: boolean
|
||||||
error?: string
|
error?: string
|
||||||
}
|
}
|
||||||
@@ -1701,7 +1704,7 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
|||||||
return { sessionIds: [] }
|
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
|
const rawSessionIds = bodyRecord.sessionIds
|
||||||
if (!Array.isArray(rawSessionIds)) {
|
if (!Array.isArray(rawSessionIds)) {
|
||||||
return { sessionIds: [], error: 'Invalid sessionIds' }
|
return { sessionIds: [], error: 'Invalid sessionIds' }
|
||||||
@@ -1720,6 +1723,14 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
|||||||
|
|
||||||
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
|
const hasModel = Object.prototype.hasOwnProperty.call(bodyRecord, 'model')
|
||||||
const hasModelReasoningEffort = Object.prototype.hasOwnProperty.call(bodyRecord, 'modelReasoningEffort')
|
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。
|
// 中文注释:前端允许多选,这里按 Codex thread 去重,避免重复导入同一条本地 transcript。
|
||||||
return {
|
return {
|
||||||
@@ -1728,6 +1739,8 @@ function parseSyncSessionRequest(body: unknown): SyncSessionRequestParseResult {
|
|||||||
machineId: typeof bodyRecord.machineId === 'string' && bodyRecord.machineId.trim() ? bodyRecord.machineId.trim() : null,
|
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,
|
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,
|
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
|
yolo: bodyRecord.yolo === true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1952,6 +1965,8 @@ export async function importSelectedCodexSessions(options: {
|
|||||||
localSessions?: RemoteCodexSession[]
|
localSessions?: RemoteCodexSession[]
|
||||||
model?: string | null
|
model?: string | null
|
||||||
modelReasoningEffort?: string | null
|
modelReasoningEffort?: string | null
|
||||||
|
serviceTier?: string | null
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
yolo?: boolean
|
yolo?: boolean
|
||||||
machineId?: string | null
|
machineId?: string | null
|
||||||
}): Promise<ScriptLaunchResponse> {
|
}): Promise<ScriptLaunchResponse> {
|
||||||
@@ -1976,6 +1991,23 @@ export async function importSelectedCodexSessions(options: {
|
|||||||
})
|
})
|
||||||
results.push(result)
|
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) {
|
if (!result.success) {
|
||||||
return {
|
return {
|
||||||
...result,
|
...result,
|
||||||
@@ -2110,6 +2142,8 @@ export function createCodexDesktopRoutes(options: {
|
|||||||
machineId: remote.machineId ?? null,
|
machineId: remote.machineId ?? null,
|
||||||
model: parsed.model,
|
model: parsed.model,
|
||||||
modelReasoningEffort: parsed.modelReasoningEffort,
|
modelReasoningEffort: parsed.modelReasoningEffort,
|
||||||
|
serviceTier: parsed.serviceTier,
|
||||||
|
collaborationMode: parsed.collaborationMode,
|
||||||
yolo: parsed.yolo
|
yolo: parsed.yolo
|
||||||
})
|
})
|
||||||
return c.json({
|
return c.json({
|
||||||
|
|||||||
@@ -51,7 +51,10 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho
|
|||||||
parsed.data.worktreeName,
|
parsed.data.worktreeName,
|
||||||
undefined,
|
undefined,
|
||||||
parsed.data.effort,
|
parsed.data.effort,
|
||||||
parsed.data.permissionMode
|
parsed.data.permissionMode,
|
||||||
|
parsed.data.serviceTier,
|
||||||
|
undefined,
|
||||||
|
parsed.data.collaborationMode
|
||||||
)
|
)
|
||||||
return c.json(result)
|
return c.json(result)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -326,7 +326,9 @@ export const SpawnSessionRequestSchema = z.object({
|
|||||||
yolo: z.boolean().optional(),
|
yolo: z.boolean().optional(),
|
||||||
permissionMode: PermissionModeSchema.optional(),
|
permissionMode: PermissionModeSchema.optional(),
|
||||||
sessionType: z.enum(['simple', 'worktree']).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>
|
export type SpawnSessionRequest = z.infer<typeof SpawnSessionRequestSchema>
|
||||||
|
|||||||
@@ -629,7 +629,9 @@ export class ApiClient {
|
|||||||
sessionType?: 'simple' | 'worktree',
|
sessionType?: 'simple' | 'worktree',
|
||||||
worktreeName?: string,
|
worktreeName?: string,
|
||||||
effort?: string,
|
effort?: string,
|
||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode,
|
||||||
|
serviceTier?: 'fast' | 'standard',
|
||||||
|
collaborationMode?: 'default' | 'plan'
|
||||||
): Promise<SpawnResponse> {
|
): Promise<SpawnResponse> {
|
||||||
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
|
return await this.request<SpawnResponse>(`/api/machines/${encodeURIComponent(machineId)}/spawn`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -642,7 +644,9 @@ export class ApiClient {
|
|||||||
sessionType,
|
sessionType,
|
||||||
worktreeName,
|
worktreeName,
|
||||||
effort,
|
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',
|
machineId: 'machine-1',
|
||||||
effort: 'auto',
|
effort: 'auto',
|
||||||
modelReasoningEffort: 'max',
|
modelReasoningEffort: 'max',
|
||||||
|
serviceTier: 'standard',
|
||||||
|
collaborationMode: 'default',
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
import { useCallback, useDeferredValue, useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from 'react'
|
||||||
import type { ApiClient } from '@/api/client'
|
import type { ApiClient } from '@/api/client'
|
||||||
import type { CodexLocalSessionSummary, Machine } from '@/types/api'
|
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 { usePlatform } from '@/hooks/usePlatform'
|
||||||
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
|
import { useMachinePathsExists } from '@/hooks/useMachinePathsExists'
|
||||||
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
|
import { useSpawnSession } from '@/hooks/mutations/useSpawnSession'
|
||||||
@@ -33,11 +34,13 @@ import {
|
|||||||
saveNewSessionFormDraft,
|
saveNewSessionFormDraft,
|
||||||
shouldRestoreNewSessionFormDraft
|
shouldRestoreNewSessionFormDraft
|
||||||
} from './newSessionFormDraft'
|
} from './newSessionFormDraft'
|
||||||
import type { AgentType, LaunchEffort, CodexReasoningEffort, SessionType } from './types'
|
import type { AgentType, LaunchEffort, CodexReasoningEffort, NewSessionServiceTier, SessionType } from './types'
|
||||||
import { ActionButtons } from './ActionButtons'
|
import { ActionButtons } from './ActionButtons'
|
||||||
import { AgentSelector } from './AgentSelector'
|
import { AgentSelector } from './AgentSelector'
|
||||||
|
import { CollaborationModeSelector } from './CollaborationModeSelector'
|
||||||
import { DirectorySection } from './DirectorySection'
|
import { DirectorySection } from './DirectorySection'
|
||||||
import { GrokPermissionModeSelector } from './GrokPermissionModeSelector'
|
import { GrokPermissionModeSelector } from './GrokPermissionModeSelector'
|
||||||
|
import { FastModeSelector } from './FastModeSelector'
|
||||||
import { MachineSelector } from './MachineSelector'
|
import { MachineSelector } from './MachineSelector'
|
||||||
import { ModelSelector } from './ModelSelector'
|
import { ModelSelector } from './ModelSelector'
|
||||||
import { OpencodeModelSelector } from './OpencodeModelSelector'
|
import { OpencodeModelSelector } from './OpencodeModelSelector'
|
||||||
@@ -129,6 +132,8 @@ export function NewSession(props: {
|
|||||||
const [effort, setEffort] = useState<LaunchEffort>('auto')
|
const [effort, setEffort] = useState<LaunchEffort>('auto')
|
||||||
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
|
const [modelReasoningEffort, setModelReasoningEffort] = useState<CodexReasoningEffort>('default')
|
||||||
const [opencodeSelectedModel, setOpencodeSelectedModel] = useState<string | null>(null)
|
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 [yoloMode, setYoloMode] = useState(loadPreferredYoloMode)
|
||||||
const [grokPermissionMode, setGrokPermissionMode] = useState<GrokPermissionMode>('default')
|
const [grokPermissionMode, setGrokPermissionMode] = useState<GrokPermissionMode>('default')
|
||||||
const [sessionType, setSessionType] = useState<SessionType>('simple')
|
const [sessionType, setSessionType] = useState<SessionType>('simple')
|
||||||
@@ -159,6 +164,8 @@ export function NewSession(props: {
|
|||||||
setEffort('auto')
|
setEffort('auto')
|
||||||
setModelReasoningEffort('default')
|
setModelReasoningEffort('default')
|
||||||
setGrokPermissionMode('default')
|
setGrokPermissionMode('default')
|
||||||
|
setServiceTier('standard')
|
||||||
|
setCollaborationMode('default')
|
||||||
if (agent !== 'cursor') {
|
if (agent !== 'cursor') {
|
||||||
setModel('auto')
|
setModel('auto')
|
||||||
setCursorSelectedBase('auto')
|
setCursorSelectedBase('auto')
|
||||||
@@ -224,6 +231,8 @@ export function NewSession(props: {
|
|||||||
setOpencodeSelectedModel(
|
setOpencodeSelectedModel(
|
||||||
draft.agent === 'opencode' && draft.model !== 'auto' ? draft.model : null
|
draft.agent === 'opencode' && draft.model !== 'auto' ? draft.model : null
|
||||||
)
|
)
|
||||||
|
setServiceTier(draft.serviceTier)
|
||||||
|
setCollaborationMode(draft.collaborationMode)
|
||||||
setYoloMode(draft.yoloMode)
|
setYoloMode(draft.yoloMode)
|
||||||
setGrokPermissionMode(draft.grokPermissionMode)
|
setGrokPermissionMode(draft.grokPermissionMode)
|
||||||
setSessionType(draft.sessionType)
|
setSessionType(draft.sessionType)
|
||||||
@@ -313,6 +322,21 @@ export function NewSession(props: {
|
|||||||
setModel('auto')
|
setModel('auto')
|
||||||
}
|
}
|
||||||
}, [agent, codexModelsState.error, codexModelsState.isLoading, codexModelsState.models, model])
|
}, [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({
|
const cursorModelsState = useCursorModelsForMachine({
|
||||||
api: props.api,
|
api: props.api,
|
||||||
machineId,
|
machineId,
|
||||||
@@ -767,6 +791,8 @@ export function NewSession(props: {
|
|||||||
machineId,
|
machineId,
|
||||||
effort,
|
effort,
|
||||||
modelReasoningEffort,
|
modelReasoningEffort,
|
||||||
|
serviceTier,
|
||||||
|
collaborationMode,
|
||||||
yoloMode,
|
yoloMode,
|
||||||
grokPermissionMode,
|
grokPermissionMode,
|
||||||
sessionType,
|
sessionType,
|
||||||
@@ -782,6 +808,8 @@ export function NewSession(props: {
|
|||||||
machineId,
|
machineId,
|
||||||
effort,
|
effort,
|
||||||
modelReasoningEffort,
|
modelReasoningEffort,
|
||||||
|
serviceTier,
|
||||||
|
collaborationMode,
|
||||||
yoloMode,
|
yoloMode,
|
||||||
grokPermissionMode,
|
grokPermissionMode,
|
||||||
sessionType,
|
sessionType,
|
||||||
@@ -894,6 +922,12 @@ export function NewSession(props: {
|
|||||||
effort,
|
effort,
|
||||||
modelReasoningEffort
|
modelReasoningEffort
|
||||||
}
|
}
|
||||||
|
const resolvedServiceTier = agent === 'codex' && showCodexFastMode
|
||||||
|
? serviceTier
|
||||||
|
: undefined
|
||||||
|
const resolvedCollaborationMode = agent === 'codex' && collaborationMode !== 'default'
|
||||||
|
? collaborationMode
|
||||||
|
: undefined
|
||||||
|
|
||||||
if (agent === 'codex' && selectedCodexImportSession) {
|
if (agent === 'codex' && selectedCodexImportSession) {
|
||||||
setIsImportingCodexSession(true)
|
setIsImportingCodexSession(true)
|
||||||
@@ -903,6 +937,8 @@ export function NewSession(props: {
|
|||||||
machineId: codexImportMachineId ?? machineId,
|
machineId: codexImportMachineId ?? machineId,
|
||||||
model: resolvedModel ?? null,
|
model: resolvedModel ?? null,
|
||||||
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
|
modelReasoningEffort: resolvedModelReasoningEffort ?? null,
|
||||||
|
serviceTier: resolvedServiceTier,
|
||||||
|
collaborationMode: resolvedCollaborationMode ?? 'default',
|
||||||
yolo: yoloMode
|
yolo: yoloMode
|
||||||
})
|
})
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
@@ -941,7 +977,9 @@ export function NewSession(props: {
|
|||||||
yolo: agent === 'grok' ? undefined : yoloMode,
|
yolo: agent === 'grok' ? undefined : yoloMode,
|
||||||
permissionMode: agent === 'grok' ? grokPermissionMode : undefined,
|
permissionMode: agent === 'grok' ? grokPermissionMode : undefined,
|
||||||
sessionType,
|
sessionType,
|
||||||
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined
|
worktreeName: sessionType === 'worktree' ? (worktreeName.trim() || undefined) : undefined,
|
||||||
|
serviceTier: resolvedServiceTier,
|
||||||
|
collaborationMode: resolvedCollaborationMode
|
||||||
})
|
})
|
||||||
|
|
||||||
if (result.type === 'success') {
|
if (result.type === 'success') {
|
||||||
@@ -984,12 +1022,16 @@ export function NewSession(props: {
|
|||||||
deferredDirectoryExists === undefined
|
deferredDirectoryExists === undefined
|
||||||
|| (deferredDirectoryExists === true && opencodeModelsState.isLoading)
|
|| (deferredDirectoryExists === true && opencodeModelsState.isLoading)
|
||||||
))
|
))
|
||||||
|
const fastModeSelectionPending = agent === 'codex'
|
||||||
|
&& serviceTier === 'fast'
|
||||||
|
&& codexModelsState.isLoading
|
||||||
const canCreate = Boolean(
|
const canCreate = Boolean(
|
||||||
machineId
|
machineId
|
||||||
&& trimmedDirectory
|
&& trimmedDirectory
|
||||||
&& !isFormDisabled
|
&& !isFormDisabled
|
||||||
&& !missingWorktreeDirectory
|
&& !missingWorktreeDirectory
|
||||||
&& !isLaunchPreferenceValidationPending
|
&& !isLaunchPreferenceValidationPending
|
||||||
|
&& !fastModeSelectionPending
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -1148,6 +1190,18 @@ export function NewSession(props: {
|
|||||||
isDisabled={isFormDisabled}
|
isDisabled={isFormDisabled}
|
||||||
onChange={setGrokPermissionMode}
|
onChange={setGrokPermissionMode}
|
||||||
/>
|
/>
|
||||||
|
<CollaborationModeSelector
|
||||||
|
agent={agent}
|
||||||
|
value={collaborationMode}
|
||||||
|
isDisabled={isFormDisabled}
|
||||||
|
onChange={setCollaborationMode}
|
||||||
|
/>
|
||||||
|
<FastModeSelector
|
||||||
|
visible={showCodexFastMode}
|
||||||
|
value={serviceTier}
|
||||||
|
isDisabled={isFormDisabled}
|
||||||
|
onChange={setServiceTier}
|
||||||
|
/>
|
||||||
{agent !== 'grok' ? (
|
{agent !== 'grok' ? (
|
||||||
<YoloToggle
|
<YoloToggle
|
||||||
yoloMode={yoloMode}
|
yoloMode={yoloMode}
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
machineId: 'machine-1',
|
machineId: 'machine-1',
|
||||||
effort: 'auto',
|
effort: 'auto',
|
||||||
modelReasoningEffort: 'default',
|
modelReasoningEffort: 'default',
|
||||||
|
serviceTier: 'standard',
|
||||||
|
collaborationMode: 'default',
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
@@ -33,6 +35,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
machineId: 'machine-1',
|
machineId: 'machine-1',
|
||||||
effort: 'auto',
|
effort: 'auto',
|
||||||
modelReasoningEffort: 'default',
|
modelReasoningEffort: 'default',
|
||||||
|
serviceTier: 'standard',
|
||||||
|
collaborationMode: 'default',
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
@@ -59,6 +63,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
machineId: null,
|
machineId: null,
|
||||||
effort: 'auto',
|
effort: 'auto',
|
||||||
modelReasoningEffort: 'default',
|
modelReasoningEffort: 'default',
|
||||||
|
serviceTier: 'standard',
|
||||||
|
collaborationMode: 'default',
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
@@ -76,6 +82,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
machineId: 'machine-a',
|
machineId: 'machine-a',
|
||||||
effort: 'auto',
|
effort: 'auto',
|
||||||
modelReasoningEffort: 'default',
|
modelReasoningEffort: 'default',
|
||||||
|
serviceTier: 'fast',
|
||||||
|
collaborationMode: 'plan',
|
||||||
yoloMode: false,
|
yoloMode: false,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
@@ -83,6 +91,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
})
|
})
|
||||||
const draft = loadNewSessionFormDraft()!
|
const draft = loadNewSessionFormDraft()!
|
||||||
expect(newSessionDraftMatchesMachine(draft, 'machine-b')).toBe(false)
|
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', () => {
|
it('coerces a stale uncreatable agent (gemini) to claude and resets dependent fields', () => {
|
||||||
@@ -93,6 +103,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
machineId: 'machine-1',
|
machineId: 'machine-1',
|
||||||
effort: 'high',
|
effort: 'high',
|
||||||
modelReasoningEffort: 'high',
|
modelReasoningEffort: 'high',
|
||||||
|
serviceTier: 'fast',
|
||||||
|
collaborationMode: 'plan',
|
||||||
yoloMode: true,
|
yoloMode: true,
|
||||||
grokPermissionMode: 'default',
|
grokPermissionMode: 'default',
|
||||||
sessionType: 'simple',
|
sessionType: 'simple',
|
||||||
@@ -106,6 +118,8 @@ describe('newSessionFormDraft', () => {
|
|||||||
expect(loaded.cursorSelectedBase).toBe('auto')
|
expect(loaded.cursorSelectedBase).toBe('auto')
|
||||||
expect(loaded.effort).toBe('auto')
|
expect(loaded.effort).toBe('auto')
|
||||||
expect(loaded.modelReasoningEffort).toBe('default')
|
expect(loaded.modelReasoningEffort).toBe('default')
|
||||||
|
expect(loaded.serviceTier).toBe('standard')
|
||||||
|
expect(loaded.collaborationMode).toBe('default')
|
||||||
// agent-independent fields preserved
|
// agent-independent fields preserved
|
||||||
expect(loaded.yoloMode).toBe(true)
|
expect(loaded.yoloMode).toBe(true)
|
||||||
expect(loaded.machineId).toBe('machine-1')
|
expect(loaded.machineId).toBe('machine-1')
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import {
|
import {
|
||||||
CREATABLE_AGENT_FLAVORS,
|
CREATABLE_AGENT_FLAVORS,
|
||||||
GROK_PERMISSION_MODES,
|
GROK_PERMISSION_MODES,
|
||||||
|
type CodexCollaborationMode,
|
||||||
type GrokPermissionMode
|
type GrokPermissionMode
|
||||||
} from '@hapi/protocol'
|
} 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'
|
const DRAFT_STORAGE_KEY = 'hapi:new-session-form-draft'
|
||||||
|
|
||||||
@@ -14,6 +15,8 @@ export type NewSessionFormDraft = {
|
|||||||
machineId: string | null
|
machineId: string | null
|
||||||
effort: LaunchEffort
|
effort: LaunchEffort
|
||||||
modelReasoningEffort: CodexReasoningEffort
|
modelReasoningEffort: CodexReasoningEffort
|
||||||
|
serviceTier: NewSessionServiceTier
|
||||||
|
collaborationMode: CodexCollaborationMode
|
||||||
yoloMode: boolean
|
yoloMode: boolean
|
||||||
grokPermissionMode: GrokPermissionMode
|
grokPermissionMode: GrokPermissionMode
|
||||||
sessionType: SessionType
|
sessionType: SessionType
|
||||||
@@ -57,6 +60,8 @@ export function loadNewSessionFormDraft(): NewSessionFormDraft | null {
|
|||||||
modelReasoningEffort: agentPreserved
|
modelReasoningEffort: agentPreserved
|
||||||
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
|
? ((parsed.modelReasoningEffort as CodexReasoningEffort | undefined) ?? 'default')
|
||||||
: 'default',
|
: 'default',
|
||||||
|
serviceTier: agentPreserved && parsed.serviceTier === 'fast' ? 'fast' : 'standard',
|
||||||
|
collaborationMode: agentPreserved && parsed.collaborationMode === 'plan' ? 'plan' : 'default',
|
||||||
yoloMode: Boolean(parsed.yoloMode),
|
yoloMode: Boolean(parsed.yoloMode),
|
||||||
grokPermissionMode: agentPreserved
|
grokPermissionMode: agentPreserved
|
||||||
&& GROK_PERMISSION_MODES.includes(parsed.grokPermissionMode as GrokPermissionMode)
|
&& GROK_PERMISSION_MODES.includes(parsed.grokPermissionMode as GrokPermissionMode)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ export type CodexReasoningEffort = string
|
|||||||
// Grok reports effort values dynamically through ACP, while Claude uses the
|
// Grok reports effort values dynamically through ACP, while Claude uses the
|
||||||
// fixed ClaudeEffortLevel catalog.
|
// fixed ClaudeEffortLevel catalog.
|
||||||
export type LaunchEffort = string
|
export type LaunchEffort = string
|
||||||
|
export type NewSessionServiceTier = 'standard' | 'fast'
|
||||||
|
|
||||||
function modelPresetOptions<TModel extends string>(
|
function modelPresetOptions<TModel extends string>(
|
||||||
presets: readonly TModel[],
|
presets: readonly TModel[],
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
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 { ApiClient } from '@/api/client'
|
||||||
import type { SpawnResponse } from '@/types/api'
|
import type { SpawnResponse } from '@/types/api'
|
||||||
import { queryKeys } from '@/lib/query-keys'
|
import { queryKeys } from '@/lib/query-keys'
|
||||||
@@ -15,6 +15,8 @@ type SpawnInput = {
|
|||||||
permissionMode?: PermissionMode
|
permissionMode?: PermissionMode
|
||||||
sessionType?: 'simple' | 'worktree'
|
sessionType?: 'simple' | 'worktree'
|
||||||
worktreeName?: string
|
worktreeName?: string
|
||||||
|
serviceTier?: 'fast' | 'standard'
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useSpawnSession(api: ApiClient | null): {
|
export function useSpawnSession(api: ApiClient | null): {
|
||||||
@@ -39,7 +41,9 @@ export function useSpawnSession(api: ApiClient | null): {
|
|||||||
input.sessionType,
|
input.sessionType,
|
||||||
input.worktreeName,
|
input.worktreeName,
|
||||||
input.effort,
|
input.effort,
|
||||||
input.permissionMode
|
input.permissionMode,
|
||||||
|
input.serviceTier,
|
||||||
|
input.collaborationMode
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ export default {
|
|||||||
'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory',
|
'newSession.opencodeModel.empty': 'No OpenCode models discovered for this directory',
|
||||||
'newSession.opencodeModel.default': 'Default',
|
'newSession.opencodeModel.default': 'Default',
|
||||||
'newSession.reasoningEffort': 'Reasoning effort',
|
'newSession.reasoningEffort': 'Reasoning effort',
|
||||||
|
'newSession.collaborationMode': 'Collaboration mode',
|
||||||
|
'newSession.fastMode': 'Fast mode',
|
||||||
'newSession.yolo': 'YOLO mode',
|
'newSession.yolo': 'YOLO mode',
|
||||||
'newSession.yolo.title': 'Bypass approvals and sandbox',
|
'newSession.yolo.title': 'Bypass approvals and sandbox',
|
||||||
'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.',
|
'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.',
|
||||||
|
|||||||
@@ -276,6 +276,8 @@ export default {
|
|||||||
'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型',
|
'newSession.opencodeModel.empty': '未在此目录发现 OpenCode 模型',
|
||||||
'newSession.opencodeModel.default': '默认',
|
'newSession.opencodeModel.default': '默认',
|
||||||
'newSession.reasoningEffort': '推理强度',
|
'newSession.reasoningEffort': '推理强度',
|
||||||
|
'newSession.collaborationMode': '协作模式',
|
||||||
|
'newSession.fastMode': '快速模式',
|
||||||
'newSession.yolo': 'YOLO 模式',
|
'newSession.yolo': 'YOLO 模式',
|
||||||
'newSession.yolo.title': '跳过审批和沙箱',
|
'newSession.yolo.title': '跳过审批和沙箱',
|
||||||
'newSession.yolo.desc': '启动时使用危险的代理标志。',
|
'newSession.yolo.desc': '启动时使用危险的代理标志。',
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type {
|
import type {
|
||||||
|
CodexCollaborationMode,
|
||||||
DecryptedMessage as ProtocolDecryptedMessage,
|
DecryptedMessage as ProtocolDecryptedMessage,
|
||||||
Machine,
|
Machine,
|
||||||
RunnerState,
|
RunnerState,
|
||||||
@@ -219,6 +220,8 @@ export type CodexDesktopSyncRequest = {
|
|||||||
machineId?: string | null
|
machineId?: string | null
|
||||||
model?: string | null
|
model?: string | null
|
||||||
modelReasoningEffort?: string | null
|
modelReasoningEffort?: string | null
|
||||||
|
serviceTier?: string | null
|
||||||
|
collaborationMode?: CodexCollaborationMode
|
||||||
yolo?: boolean
|
yolo?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user