diff --git a/bun.lock b/bun.lock index f31e993406..10582f8923 100644 --- a/bun.lock +++ b/bun.lock @@ -1062,6 +1062,8 @@ "@twsxtd/hapi-linux-x64": ["@twsxtd/hapi-linux-x64@0.16.7", "", { "os": "linux", "cpu": "x64", "bin": { "hapi": "bin/hapi" } }, "sha512-JuqgwJev9bHg57EqS+pGWXJ5tBtV3Xm5MFmoMNWXLuRVegNrWTO5WJHRsPH5XIItXtam5/aThKy73WEaTde4IA=="], + "@twsxtd/hapi-win32-x64": ["@twsxtd/hapi-win32-x64@0.16.7", "", { "os": "win32", "cpu": "x64", "bin": { "hapi": "bin/hapi.exe" } }, "sha512-/1i03Ft13f2HDBj/W26UvUv6kBjZ/GtIdTaaZFTrw7RQ6MQ4zKbfQEJjqMtnasWE1WuqFu8uFI8IClQBIQuRiQ=="], + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], diff --git a/cli/package.json b/cli/package.json index 1a6d65cce8..ef053f50bf 100644 --- a/cli/package.json +++ b/cli/package.json @@ -35,6 +35,7 @@ "scripts": { "postinstall": "node -e \"try{require('fs').chmodSync(require('path').join(__dirname,'bin','hapi.cjs'),0o755)}catch(e){}\"", "typecheck": "tsc --noEmit", + "build": "bun run typecheck", "build:exe": "bun run scripts/build-executable.ts", "build:exe:all": "bun run scripts/build-executable.ts --all", "build:exe:allinone": "bun run scripts/build-executable.ts --with-web-assets", diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index cc43ee8b3b..735a38a7af 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -6,7 +6,8 @@ import type { SessionEffort, SessionModel, SessionModelReasoningEffort, - SessionPermissionMode + SessionPermissionMode, + SessionServiceTier } from '@/api/types'; import { logger } from '@/ui/logger'; @@ -26,6 +27,7 @@ export type AgentSessionBaseOptions = { model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; effort?: SessionEffort; + serviceTier?: SessionServiceTier; collaborationMode?: SessionCollaborationMode; }; @@ -50,6 +52,7 @@ export class AgentSessionBase { protected model?: SessionModel; protected modelReasoningEffort?: SessionModelReasoningEffort; protected effort?: SessionEffort; + protected serviceTier?: SessionServiceTier; protected collaborationMode?: SessionCollaborationMode; constructor(opts: AgentSessionBaseOptions) { @@ -68,6 +71,7 @@ export class AgentSessionBase { this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; this.effort = opts.effort; + this.serviceTier = opts.serviceTier; this.collaborationMode = opts.collaborationMode; this.queue.onBatchConsumed = (localIds) => this.client.emitMessagesConsumed(localIds); @@ -91,10 +95,11 @@ export class AgentSessionBase { const modelLabel = this.model === undefined ? 'unset' : (this.model ?? 'auto'); const modelReasoningEffortLabel = this.modelReasoningEffort === undefined ? 'unset' : (this.modelReasoningEffort ?? 'default'); const effortLabel = this.effort === undefined ? 'unset' : (this.effort ?? 'auto'); + const serviceTierLabel = this.serviceTier === undefined ? 'unset' : (this.serviceTier ?? 'default'); const collaborationLabel = this.collaborationMode ?? 'unset'; logger.debug( `[${this.sessionLabel}] Mode switched to ${mode} ` + - `(permissionMode=${permissionLabel}, model=${modelLabel}, modelReasoningEffort=${modelReasoningEffortLabel}, effort=${effortLabel}, collaborationMode=${collaborationLabel})` + `(permissionMode=${permissionLabel}, model=${modelLabel}, modelReasoningEffort=${modelReasoningEffortLabel}, effort=${effortLabel}, serviceTier=${serviceTierLabel}, collaborationMode=${collaborationLabel})` ); this._onModeChange(mode); }; @@ -133,6 +138,7 @@ export class AgentSessionBase { model?: SessionModel modelReasoningEffort?: SessionModelReasoningEffort effort?: SessionEffort + serviceTier?: SessionServiceTier collaborationMode?: SessionCollaborationMode } | undefined { if ( @@ -140,6 +146,7 @@ export class AgentSessionBase { && this.model === undefined && this.modelReasoningEffort === undefined && this.effort === undefined + && this.serviceTier === undefined && this.collaborationMode === undefined ) { return undefined; @@ -149,6 +156,7 @@ export class AgentSessionBase { model: this.model, modelReasoningEffort: this.modelReasoningEffort, effort: this.effort, + serviceTier: this.serviceTier, collaborationMode: this.collaborationMode }; } @@ -169,6 +177,10 @@ export class AgentSessionBase { return this.effort; } + getServiceTier(): SessionServiceTier | undefined { + return this.serviceTier; + } + getCollaborationMode(): SessionCollaborationMode | undefined { return this.collaborationMode; } diff --git a/cli/src/agent/sessionFactory.ts b/cli/src/agent/sessionFactory.ts index ddbea808cf..34dfbc7992 100644 --- a/cli/src/agent/sessionFactory.ts +++ b/cli/src/agent/sessionFactory.ts @@ -25,6 +25,7 @@ export type SessionBootstrapOptions = { model?: string modelReasoningEffort?: string effort?: string + serviceTier?: string metadataOverrides?: Partial } @@ -135,7 +136,8 @@ export async function bootstrapSession(options: SessionBootstrapOptions): Promis state: agentState, model: options.model, modelReasoningEffort: options.modelReasoningEffort, - effort: options.effort + effort: options.effort, + serviceTier: options.serviceTier }) const session = api.sessionSyncClient(sessionInfo) diff --git a/cli/src/api/api.extraHeaders.test.ts b/cli/src/api/api.extraHeaders.test.ts index a21587dc8e..c72e9a7c7d 100644 --- a/cli/src/api/api.extraHeaders.test.ts +++ b/cli/src/api/api.extraHeaders.test.ts @@ -137,6 +137,7 @@ describe('API extra headers integration', () => { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: undefined, collaborationMode: undefined }) diff --git a/cli/src/api/api.ts b/cli/src/api/api.ts index 5e05e77e1f..0a833093ad 100644 --- a/cli/src/api/api.ts +++ b/cli/src/api/api.ts @@ -22,6 +22,7 @@ export class ApiClient { model?: string modelReasoningEffort?: string effort?: string + serviceTier?: string }): Promise { const response = await axios.post( `${configuration.apiUrl}/cli/sessions`, @@ -31,7 +32,8 @@ export class ApiClient { agentState: opts.state, model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, - effort: opts.effort + effort: opts.effort, + serviceTier: opts.serviceTier }, { headers: buildHubRequestHeaders({ @@ -79,6 +81,7 @@ export class ApiClient { model: raw.model, modelReasoningEffort: raw.modelReasoningEffort, effort: raw.effort, + serviceTier: raw.serviceTier, permissionMode: raw.permissionMode, collaborationMode: raw.collaborationMode } diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts new file mode 100644 index 0000000000..84f9438d25 --- /dev/null +++ b/cli/src/api/apiMachine.test.ts @@ -0,0 +1,177 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +type FakeSocket = { + handlers: Map void> + emitted: Array<{ event: string, payload: unknown }> + on: (event: string, handler: (...args: any[]) => void) => FakeSocket + emit: (event: string, payload: unknown) => void + emitWithAck: (event: string, payload: unknown) => Promise + close: () => void +} + +const listImportableCodexSessionsMock = vi.hoisted(() => vi.fn()) +const listImportableClaudeSessionsMock = vi.hoisted(() => vi.fn()) +const fakeSocket = vi.hoisted(() => ({ + handlers: new Map(), + emitted: [], + on(event, handler) { + this.handlers.set(event, handler) + return this + }, + emit(event, payload) { + this.emitted.push({ event, payload }) + }, + emitWithAck: vi.fn(async (event: string) => { + if (event === 'machine-update-state') { + return { result: 'success', version: 1, runnerState: null } + } + + if (event === 'machine-update-metadata') { + return { result: 'success', version: 1, metadata: null } + } + + return { result: 'success', version: 1 } + }), + close() {} +})) + +const importableSessionsResponse = { + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/sessions/codex-session-1.jsonl', + previewTitle: 'Project draft', + previewPrompt: 'Build the project' + } + ] +} + +const importableClaudeSessionsResponse = { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/sessions/claude-session-1.jsonl', + previewTitle: 'Continue the refactor', + previewPrompt: 'Continue the refactor' + } + ] +} + +vi.mock('socket.io-client', () => ({ + io: vi.fn(() => fakeSocket) +})) + +vi.mock('@/codex/utils/listImportableCodexSessions', () => ({ + listImportableCodexSessions: listImportableCodexSessionsMock +})) + +vi.mock('@/claude/utils/listImportableClaudeSessions', () => ({ + listImportableClaudeSessions: listImportableClaudeSessionsMock +})) + +vi.mock('@/modules/common/registerCommonHandlers', () => ({ + registerCommonHandlers: vi.fn() +})) + +vi.mock('@/utils/invokedCwd', () => ({ + getInvokedCwd: vi.fn(() => '/workspace') +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})) + +import { ApiMachineClient } from './apiMachine' + +describe('ApiMachineClient list-importable-sessions RPC', () => { + beforeEach(() => { + fakeSocket.handlers.clear() + fakeSocket.emitted.length = 0 + vi.mocked(fakeSocket.emitWithAck).mockClear() + listImportableCodexSessionsMock.mockReset() + listImportableClaudeSessionsMock.mockReset() + listImportableCodexSessionsMock.mockResolvedValue(importableSessionsResponse) + listImportableClaudeSessionsMock.mockResolvedValue(importableClaudeSessionsResponse) + }) + + it('registers the RPC during connect and returns scanner results by agent', async () => { + const machine = { + id: 'machine-1', + metadata: null, + metadataVersion: 0, + runnerState: null, + runnerStateVersion: 0 + } as never + + const client = new ApiMachineClient('token', machine) + client.connect() + + const connectHandler = fakeSocket.handlers.get('connect') + expect(connectHandler).toBeTypeOf('function') + connectHandler?.() + + expect(fakeSocket.emitted).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + event: 'rpc-register', + payload: { method: 'machine-1:path-exists' } + }), + expect.objectContaining({ + event: 'rpc-register', + payload: { method: 'machine-1:list-importable-sessions' } + }) + ]) + ) + + const rpcRequestHandler = fakeSocket.handlers.get('rpc-request') + expect(rpcRequestHandler).toBeTypeOf('function') + + const codexResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'codex' }) + }, + resolve + ) + }) + + expect(codexResponse).toBe(JSON.stringify(importableSessionsResponse)) + + const missingAgentResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({}) + }, + resolve + ) + }) + + expect(missingAgentResponse).toBe(JSON.stringify({ sessions: [] })) + expect(listImportableCodexSessionsMock).toHaveBeenCalledTimes(1) + + const claudeResponse = await new Promise((resolve) => { + rpcRequestHandler?.( + { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'claude' }) + }, + resolve + ) + }) + + expect(JSON.parse(claudeResponse)).toEqual(importableClaudeSessionsResponse) + expect(listImportableClaudeSessionsMock).toHaveBeenCalledTimes(1) + + client.shutdown() + }) +}) diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index da7fab7ad3..12191ac385 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -13,7 +13,14 @@ import { backoff } from '@/utils/time' import { getInvokedCwd } from '@/utils/invokedCwd' import { RpcHandlerManager } from './rpc/RpcHandlerManager' import { registerCommonHandlers } from '../modules/common/registerCommonHandlers' -import type { SpawnSessionOptions, SpawnSessionResult } from '../modules/common/rpcTypes' +import type { + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse, + SpawnSessionOptions, + SpawnSessionResult +} from '../modules/common/rpcTypes' +import { listImportableClaudeSessions } from '@/claude/utils/listImportableClaudeSessions' +import { listImportableCodexSessions } from '@/codex/utils/listImportableCodexSessions' import { applyVersionedAck } from './versionedUpdate' import { buildSocketIoExtraHeaderOptions } from './hubExtraHeaders' @@ -80,30 +87,13 @@ export class ApiMachineClient { }) registerCommonHandlers(this.rpcHandlerManager, getInvokedCwd()) - - this.rpcHandlerManager.registerHandler('path-exists', async (params) => { - const rawPaths = Array.isArray(params?.paths) ? params.paths : [] - const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) - const exists: Record = {} - - await Promise.all(uniquePaths.map(async (path) => { - const trimmed = path.trim() - if (!trimmed) return - try { - const stats = await stat(trimmed) - exists[trimmed] = stats.isDirectory() - } catch { - exists[trimmed] = false - } - })) - - return { exists } - }) + this.registerMachineHandlers() } setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { + this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, serviceTier, yolo, permissionMode, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -119,6 +109,7 @@ export class ApiMachineClient { model, effort, modelReasoningEffort, + serviceTier, yolo, permissionMode, token, @@ -156,6 +147,41 @@ export class ApiMachineClient { }) } + private registerMachineHandlers(): void { + this.rpcHandlerManager.registerHandler('path-exists', async (params) => { + const rawPaths = Array.isArray(params?.paths) ? params.paths : [] + const uniquePaths = Array.from(new Set(rawPaths.filter((path): path is string => typeof path === 'string'))) + const exists: Record = {} + + await Promise.all(uniquePaths.map(async (path) => { + const trimmed = path.trim() + if (!trimmed) return + try { + const stats = await stat(trimmed) + exists[trimmed] = stats.isDirectory() + } catch { + exists[trimmed] = false + } + })) + + return { exists } + }) + + this.rpcHandlerManager.registerHandler( + 'list-importable-sessions', + async (params) => { + if (params?.agent === 'codex') { + return await listImportableCodexSessions() + } + + if (params?.agent === 'claude') { + return await listImportableClaudeSessions() + } + + return { sessions: [] } + } + ) + } async updateMachineMetadata(handler: (metadata: MachineMetadata | null) => MachineMetadata): Promise { await backoff(async () => { const updated = handler(this.machine.metadata) diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 5a7a8db33e..1bb10943ff 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -431,14 +431,27 @@ export class ApiSessionClient extends EventEmitter { } sendAgentMessage(body: unknown): void { + const bodyRecord = body && typeof body === 'object' && !Array.isArray(body) + ? body as Record + : null + const bodyMeta = bodyRecord && bodyRecord.meta && typeof bodyRecord.meta === 'object' && !Array.isArray(bodyRecord.meta) + ? bodyRecord.meta as Record + : null + const data = bodyMeta && bodyRecord + ? (() => { + const { meta: _meta, ...rest } = bodyRecord + return rest + })() + : body const content = { role: 'agent', content: { type: AGENT_MESSAGE_PAYLOAD_TYPE, - data: body + data }, meta: { - sentFrom: 'cli' + sentFrom: 'cli', + ...(bodyMeta ?? {}) } } this.socket.emit('message', { @@ -482,6 +495,7 @@ export class ApiSessionClient extends EventEmitter { model?: SessionModel modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: SessionCollaborationMode } ): void { diff --git a/cli/src/api/types.ts b/cli/src/api/types.ts index e2a90a31ab..d26c6b32f5 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -26,6 +26,7 @@ export type SessionCollaborationMode = CodexCollaborationMode export type SessionModel = string | null export type SessionModelReasoningEffort = string | null export type SessionEffort = string | null +export type SessionServiceTier = string | null export { AgentStateSchema, AttachmentMetadataSchema, MetadataSchema } @@ -103,6 +104,7 @@ export const CreateSessionResponseSchema = z.object({ model: z.string().nullable().optional().default(null), modelReasoningEffort: z.string().nullable().optional().default(null), effort: z.string().nullable().optional().default(null), + serviceTier: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) @@ -133,7 +135,9 @@ export const MessageMetaSchema = z.object({ customSystemPrompt: z.string().nullable().optional(), appendSystemPrompt: z.string().nullable().optional(), allowedTools: z.array(z.string()).nullable().optional(), - disallowedTools: z.array(z.string()).nullable().optional() + disallowedTools: z.array(z.string()).nullable().optional(), + isSidechain: z.boolean().optional(), + sidechainKey: z.string().optional() }) export type MessageMeta = z.infer diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts new file mode 100644 index 0000000000..cd5166c224 --- /dev/null +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -0,0 +1,274 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RawJSONLinesSchema } from './types' + +const harness = { + replayMessages: [] as Array>, + remoteMessages: [] as Array>, + scannerCalls: [] as Array>, + metadataUpdates: [] as Array>, + sessionEvents: [] as Array>, + rpcHandlers: new Map Promise | unknown>(), + expectedReplaySessionId: 'resume-session-123', +} + +vi.mock('./claudeRemote', () => ({ + claudeRemote: async (opts: { + onMessage: (message: Record) => void + }) => { + const messages = harness.remoteMessages.length > 0 + ? harness.remoteMessages + : [{ + type: 'assistant', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'live assistant reply' }] + } + }] + for (const message of messages) { + opts.onMessage(message) + } + queueMicrotask(() => { + void harness.rpcHandlers.get('switch')?.({}) + }) + } +})) + +vi.mock('./utils/sessionScanner', () => ({ + createSessionScanner: async (opts: { + sessionId: string | null; + replayExistingMessages?: boolean; + onMessage: (message: Record) => void + }) => { + harness.scannerCalls.push(opts as Record) + expect(opts.sessionId).toBe(harness.expectedReplaySessionId) + expect(opts.replayExistingMessages).toBe(true) + for (const message of harness.replayMessages) { + const parsed = RawJSONLinesSchema.safeParse(message) + expect(parsed.success).toBe(true) + if (parsed.success) { + opts.onMessage(parsed.data) + } + } + return { + cleanup: async () => {}, + onNewSession: () => {} + } + } +})) + +vi.mock('./utils/permissionHandler', () => ({ + PermissionHandler: class { + constructor() {} + setOnPermissionRequest(): void {} + onMessage(): void {} + getResponses(): Map { + return new Map() + } + handleToolCall(): Promise<{ behavior: 'allow' }> { + return Promise.resolve({ behavior: 'allow' }) + } + isAborted(): boolean { + return false + } + handleModeChange(): void {} + reset(): void {} + } +})) + +vi.mock('./utils/OutgoingMessageQueue', () => ({ + OutgoingMessageQueue: class { + constructor(private readonly send: (message: Record) => void) {} + enqueue(message: Record): void { + this.send(message) + } + releaseToolCall(): void {} + async flush(): Promise {} + destroy(): void {} + } +})) + +vi.mock('@/ui/messageFormatterInk', () => ({ + formatClaudeMessageForInk: () => {} +})) + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + debugLargeJson: () => {} + } +})) + +import { claudeRemoteLauncher } from './claudeRemoteLauncher' + +function createSessionStub() { + const sentClaudeMessages: Array> = [] + const sessionFoundCallbacks = new Set<(sessionId: string) => void>() + let explicitResumeReplayConsumed = false + + const session: { + sessionId: string | null; + path: string; + logPath: string; + startedBy: 'runner'; + startingMode: 'remote'; + claudeEnvVars: Record; + claudeArgs: string[]; + mcpServers: Record; + allowedTools: string[]; + hookSettingsPath: string; + queue: { + size: () => number; + waitForMessagesAndGetAsString: () => Promise; + }; + client: { + sendClaudeSessionMessage: (message: Record) => void; + sendSessionEvent: (event: Record) => void; + updateMetadata: (handler: (metadata: Record) => Record) => void; + rpcHandlerManager: { + registerHandler: (method: string, handler: (params?: unknown) => Promise | unknown) => void; + }; + }; + addSessionFoundCallback: (callback: (sessionId: string) => void) => void; + removeSessionFoundCallback: (callback: (sessionId: string) => void) => void; + onSessionFound: (sessionId: string) => void; + onThinkingChange: () => void; + clearSessionId: () => void; + consumeExplicitRemoteResumeReplaySessionId: () => string | null; + consumeOneTimeFlags: () => void; + } = { + sessionId: null, + path: '/tmp/hapi-update', + logPath: '/tmp/hapi-update/test.log', + startedBy: 'runner' as const, + startingMode: 'remote' as const, + claudeEnvVars: {}, + claudeArgs: ['--resume', 'resume-session-123'], + mcpServers: {}, + allowedTools: [], + hookSettingsPath: '/tmp/hapi-update/hooks.json', + queue: { + size: () => 0, + waitForMessagesAndGetAsString: async () => null, + }, + client: { + sendClaudeSessionMessage: (message: Record) => { + sentClaudeMessages.push(message) + }, + sendSessionEvent: (event: Record) => { + harness.sessionEvents.push(event) + }, + updateMetadata: (handler: (metadata: Record) => Record) => { + const next = handler({ summary: null }) + harness.metadataUpdates.push(next) + }, + rpcHandlerManager: { + registerHandler(method: string, handler: (params?: unknown) => Promise | unknown) { + harness.rpcHandlers.set(method, handler) + } + } + }, + addSessionFoundCallback(callback: (sessionId: string) => void) { + sessionFoundCallbacks.add(callback) + }, + removeSessionFoundCallback(callback: (sessionId: string) => void) { + sessionFoundCallbacks.delete(callback) + }, + onSessionFound(sessionId: string) { + session.sessionId = sessionId + session.client.updateMetadata((metadata) => ({ + ...metadata, + claudeSessionId: sessionId + })) + for (const callback of sessionFoundCallbacks) { + callback(sessionId) + } + }, + onThinkingChange: () => {}, + clearSessionId: () => { + session.sessionId = null + }, + consumeExplicitRemoteResumeReplaySessionId: () => { + if (explicitResumeReplayConsumed) { + return null + } + explicitResumeReplayConsumed = true + return session.claudeArgs[1] ?? null + }, + consumeOneTimeFlags: () => {}, + } + + return { + session, + sentClaudeMessages, + } +} + +describe('claudeRemoteLauncher explicit remote resume replay', () => { + afterEach(() => { + harness.replayMessages = [] + harness.remoteMessages = [] + harness.scannerCalls = [] + harness.metadataUpdates = [] + harness.sessionEvents = [] + harness.rpcHandlers = new Map() + harness.expectedReplaySessionId = 'resume-session-123' + }) + + it('replays transcript history before live remote Claude messages and seeds the imported session id', async () => { + harness.replayMessages = [ + { type: 'system', subtype: 'init', uuid: 'init-1' }, + { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } }, + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + + expect(harness.scannerCalls).toEqual([ + expect.objectContaining({ + sessionId: 'resume-session-123', + replayExistingMessages: true + }) + ]) + expect(harness.metadataUpdates).toContainEqual(expect.objectContaining({ + claudeSessionId: 'resume-session-123' + })) + expect(sentClaudeMessages[0]).toEqual(expect.objectContaining({ + type: 'user', + message: expect.objectContaining({ content: 'existing user prompt' }) + })) + expect(sentClaudeMessages[1]).toEqual(expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'existing assistant reply' }] + }) + })) + expect(sentClaudeMessages.some((message) => { + const content = (message.message as Record | undefined)?.content + return Array.isArray(content) + && content.some((block) => (block as Record).text === 'live assistant reply') + })).toBe(true) + }) + + it('replays transcript history only once for an explicit Claude remote resume session', async () => { + harness.replayMessages = [ + { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } } + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + const firstLaunchCount = sentClaudeMessages.length + + await claudeRemoteLauncher(session as never) + + expect(harness.scannerCalls).toHaveLength(1) + expect(sentClaudeMessages.slice(firstLaunchCount).some((message) => { + const content = (message.message as Record | undefined)?.content + return Array.isArray(content) + && content.some((block) => (block as Record).text === 'existing assistant reply') + })).toBe(false) + }) +}) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f4..e4a8450b55 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -11,6 +11,8 @@ import { SDKToLogConverter } from "./utils/sdkToLogConverter"; import { PLAN_FAKE_REJECT } from "./sdk/prompts"; import { EnhancedMode } from "./loop"; import { OutgoingMessageQueue } from "./utils/OutgoingMessageQueue"; +import { createSessionScanner } from "./utils/sessionScanner"; +import { isClaudeChatVisibleMessage } from "./utils/chatVisibility"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, @@ -108,12 +110,43 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { version: process.env.npm_package_version }, permissionHandler.getResponses()); + const replayExplicitResumeTranscript = async (): Promise => { + if (session.startingMode !== 'remote') { + return; + } + + const resumeSessionId = session.consumeExplicitRemoteResumeReplaySessionId(); + if (!resumeSessionId) { + return; + } + + if (session.sessionId !== resumeSessionId) { + session.onSessionFound(resumeSessionId); + } + + const scanner = await createSessionScanner({ + sessionId: resumeSessionId, + workingDirectory: session.path, + replayExistingMessages: true, + onMessage: (message) => { + if (!isClaudeChatVisibleMessage(message)) { + return; + } + session.client.sendClaudeSessionMessage(message); + } + }); + + await scanner.cleanup(); + }; + const handleSessionFound = (sessionId: string) => { sdkToLogConverter.updateSessionId(sessionId); }; this.handleSessionFound = handleSessionFound; session.addSessionFoundCallback(handleSessionFound); + await replayExplicitResumeTranscript(); + let planModeToolCalls = new Set(); let ongoingToolCalls = new Map(); diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 975bcb2da4..7203f0f449 100644 --- a/cli/src/claude/session.ts +++ b/cli/src/claude/session.ts @@ -6,6 +6,7 @@ import type { SessionEffort, SessionModel } from '@/api/types'; import type { EnhancedMode } from './loop'; import type { PermissionMode } from './loop'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; +import { extractExplicitResumeSessionId, isExplicitResumeSessionId } from './utils/explicitResume'; type LocalLaunchFailure = { message: string; @@ -21,6 +22,7 @@ export class Session extends AgentSessionBase { readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; + private explicitRemoteResumeReplayConsumed = false; constructor(opts: { api: ApiClient; @@ -98,6 +100,21 @@ export class Session extends AgentSessionBase { logger.debug('[Session] Session ID cleared'); }; + consumeExplicitRemoteResumeReplaySessionId = (): string | null => { + if (this.explicitRemoteResumeReplayConsumed) { + return null; + } + + const resumeSessionId = extractExplicitResumeSessionId(this.claudeArgs); + if (!resumeSessionId) { + return null; + } + + this.explicitRemoteResumeReplayConsumed = true; + logger.debug(`[Session] Consumed explicit remote resume replay session ID: ${resumeSessionId}`); + return resumeSessionId; + }; + /** * Consume one-time Claude flags from claudeArgs after Claude spawn * Currently handles: --resume (with or without session ID) @@ -111,8 +128,7 @@ export class Session extends AgentSessionBase { // Check if next arg looks like a UUID (contains dashes and alphanumeric) if (i + 1 < this.claudeArgs.length) { const nextArg = this.claudeArgs[i + 1]; - // Simple UUID pattern check - contains dashes and is not another flag - if (!nextArg.startsWith('-') && nextArg.includes('-')) { + if (isExplicitResumeSessionId(nextArg)) { // Skip both --resume and the UUID i++; // Skip the UUID logger.debug(`[Session] Consumed --resume flag with session ID: ${nextArg}`); diff --git a/cli/src/claude/utils/explicitResume.ts b/cli/src/claude/utils/explicitResume.ts new file mode 100644 index 0000000000..02c122e2ad --- /dev/null +++ b/cli/src/claude/utils/explicitResume.ts @@ -0,0 +1,24 @@ +export function extractExplicitResumeSessionId(args?: string[]): string | null { + if (!args) { + return null; + } + + for (let i = 0; i < args.length; i++) { + if (args[i] !== '--resume') { + continue; + } + + if (i + 1 >= args.length) { + return null; + } + + const nextArg = args[i + 1]; + return isExplicitResumeSessionId(nextArg) ? nextArg : null; + } + + return null; +} + +export function isExplicitResumeSessionId(value: string): boolean { + return !value.startsWith('-') && value.includes('-'); +} diff --git a/cli/src/claude/utils/listImportableClaudeSessions.test.ts b/cli/src/claude/utils/listImportableClaudeSessions.test.ts new file mode 100644 index 0000000000..b941c7eb25 --- /dev/null +++ b/cli/src/claude/utils/listImportableClaudeSessions.test.ts @@ -0,0 +1,188 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { existsSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { tmpdir } from 'node:os' +import { listImportableClaudeSessions } from './listImportableClaudeSessions' + +describe('listImportableClaudeSessions', () => { + let testDir: string + + beforeEach(async () => { + testDir = join(tmpdir(), `claude-importable-sessions-${Date.now()}`) + await mkdir(testDir, { recursive: true }) + }) + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }) + } + }) + + it('uses the resumed root Claude session from the real fixture instead of carried-over history', async () => { + const fixtureContent = await readFile(join(__dirname, '__fixtures__', '1-continue-run-ls-tool.jsonl'), 'utf-8') + const sessionsRoot = join(testDir, 'sessions') + await mkdir(sessionsRoot, { recursive: true }) + + const fixturePath = join(sessionsRoot, '1-continue-run-ls-tool.jsonl') + await writeFile(fixturePath, fixtureContent) + + const result = await listImportableClaudeSessions({ rootDir: sessionsRoot }) + + expect(result.sessions).toHaveLength(1) + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + externalSessionId: '789e105f-ae33-486d-9271-0696266f072d', + cwd: '/Users/kirilldubovitskiy/projects/happy/handy-cli/notes/test-project', + timestamp: Date.parse('2025-07-19T22:32:32.898Z'), + transcriptPath: fixturePath, + previewPrompt: 'run ls tool', + previewTitle: 'run ls tool' + }) + + expect(result.sessions[0].externalSessionId).not.toBe('93a9705e-bc6a-406d-8dce-8acc014dedbd') + expect(result.sessions[0].previewPrompt).not.toBe('say lol') + expect(result.sessions[0].previewTitle).not.toBe('Casual Chat: Simple Greeting Exchange') + }) + + it('derives Claude importable session summaries from project jsonl files', async () => { + const olderDir = join(testDir, '2026', '04', '03') + const newerDir = join(testDir, '2026', '04', '04') + await mkdir(olderDir, { recursive: true }) + await mkdir(newerDir, { recursive: true }) + + const olderSessionId = 'session-a' + const olderFile = join(olderDir, `${olderSessionId}.jsonl`) + await writeFile( + olderFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: olderSessionId, + cwd: '/work/project-a', + timestamp: '2026-04-03T09:00:00.000Z' + } + }), + JSON.stringify({ + type: 'assistant', + uuid: 'assistant-older-1', + cwd: '/work/project-a', + timestamp: '2026-04-03T09:00:01.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Acknowledged' }] + } + }) + ].join('\n') + '\n' + ) + + const newerSessionId = 'session-b' + const newerFile = join(newerDir, 'project-b-transcript.jsonl') + await writeFile( + newerFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: newerSessionId, + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:00.000Z' + } + }), + JSON.stringify({ + type: 'user', + uuid: 'user-0', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:00.500Z', + message: { + role: 'user', + content: ' internal Claude injection' + } + }), + JSON.stringify({ + type: 'user', + uuid: 'user-1', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:01.000Z', + message: { + role: 'user', + content: [ + { type: 'text', text: 'Continue the' }, + { type: 'text', text: 'refactor' } + ] + } + }), + JSON.stringify({ + type: 'user', + uuid: 'user-2', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:03.000Z', + message: { + role: 'user', + content: 'Ignore this later user prompt' + } + }), + JSON.stringify({ + type: 'assistant', + uuid: 'assistant-1', + cwd: '/work/project-b', + timestamp: '2026-04-04T12:00:02.000Z', + message: { + role: 'assistant', + content: [{ type: 'text', text: 'Working on it' }] + } + }) + ].join('\n') + '\n' + ) + + const ignoredSessionFile = join(newerDir, 'ignored.jsonl') + await writeFile( + ignoredSessionFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'ignored-session', + cwd: '/work/ignored', + timestamp: '2026-04-04T13:00:00.000Z' + } + }), + JSON.stringify({ + type: 'system', + subtype: 'init', + uuid: 'system-ignored' + }) + ].join('\n') + '\n' + ) + + const result = await listImportableClaudeSessions({ rootDir: testDir }) + + expect(result.sessions.map((session) => session.externalSessionId)).toEqual([ + newerSessionId, + olderSessionId + ]) + + expect(result.sessions[0]).toMatchObject({ + agent: 'claude', + externalSessionId: newerSessionId, + cwd: '/work/project-b', + timestamp: Date.parse('2026-04-04T12:00:00.000Z'), + transcriptPath: newerFile, + previewPrompt: 'Continue the refactor', + previewTitle: 'Continue the refactor' + }) + + expect(result.sessions[1]).toMatchObject({ + agent: 'claude', + externalSessionId: olderSessionId, + cwd: '/work/project-a', + timestamp: Date.parse('2026-04-03T09:00:00.000Z'), + transcriptPath: olderFile, + previewPrompt: null, + previewTitle: 'project-a' + }) + + expect(result.sessions.find((session) => session.externalSessionId === 'ignored-session')).toBeUndefined() + }) +}) diff --git a/cli/src/claude/utils/listImportableClaudeSessions.ts b/cli/src/claude/utils/listImportableClaudeSessions.ts new file mode 100644 index 0000000000..a12d50a2eb --- /dev/null +++ b/cli/src/claude/utils/listImportableClaudeSessions.ts @@ -0,0 +1,398 @@ +import { homedir } from 'node:os' +import { basename, join } from 'node:path' +import { readdir, readFile } from 'node:fs/promises' +import type { ImportableClaudeSessionSummary } from '@hapi/protocol/rpcTypes' +import { RawJSONLinesSchema, type RawJSONLines } from '@/claude/types' +import { isClaudeChatVisibleMessage } from './chatVisibility' + +export type ListImportableClaudeSessionsOptions = { + rootDir?: string +} + +const SYSTEM_INJECTION_PREFIXES = [ + '', + '', + '', + '' +] + +export async function listImportableClaudeSessions( + opts: ListImportableClaudeSessionsOptions = {} +): Promise<{ sessions: ImportableClaudeSessionSummary[] }> { + const sessionsRoot = opts.rootDir?.trim() ? opts.rootDir : getClaudeSessionsRoot() + const transcriptPaths = (await collectJsonlFiles(sessionsRoot)).sort((a, b) => a.localeCompare(b)) + const summaries = (await Promise.all(transcriptPaths.map(async (transcriptPath) => scanClaudeTranscript(transcriptPath)))) + .filter((summary): summary is ImportableClaudeSessionSummary => summary !== null) + + summaries.sort(compareImportableClaudeSessions) + + return { sessions: summaries } +} + +async function scanClaudeTranscript(transcriptPath: string): Promise { + let content: string + try { + content = await readFile(transcriptPath, 'utf-8') + } catch { + return null + } + + const lines = content.split(/\r?\n/) + const records = lines + .map((line, lineIndex) => ({ + lineIndex, + record: parseJsonLine(line) + })) + .filter((entry): entry is { lineIndex: number; record: Record } => entry.record !== null) + + const rootSessionId = findRootSessionId(records) + if (!rootSessionId) { + return null + } + + const rootStartIndex = findRootSessionStartIndex(records, rootSessionId) + + let cwd: string | null = null + let timestamp: number | null = null + let explicitTitle: string | null = null + let previewPrompt: string | null = null + let hasVisibleMessage = false + + for (const entry of records) { + if (entry.lineIndex < rootStartIndex) { + continue + } + + const sessionMeta = extractSessionMeta(entry.record) + if (cwd === null && sessionMeta.cwd !== null) { + cwd = sessionMeta.cwd + } + if (timestamp === null && sessionMeta.timestamp !== null) { + timestamp = sessionMeta.timestamp + } + if (explicitTitle === null && sessionMeta.explicitTitle !== null) { + explicitTitle = sessionMeta.explicitTitle + } + + const rawMessage = parseRawClaudeMessage(entry.record) + if (!rawMessage) { + continue + } + + if (!isClaudeChatVisibleMessage(rawMessage)) { + continue + } + + hasVisibleMessage = true + if (!previewPrompt && isRealClaudeUserMessage(rawMessage)) { + previewPrompt = extractUserPrompt(rawMessage) + } + } + + if (!hasVisibleMessage) { + return null + } + + const previewTitle = explicitTitle + ?? previewPrompt + ?? deriveCwdPreview(cwd) + ?? rootSessionId + + return { + agent: 'claude', + externalSessionId: rootSessionId, + cwd, + timestamp, + transcriptPath, + previewTitle, + previewPrompt + } +} + +function getClaudeSessionsRoot(): string { + const claudeHome = process.env.CLAUDE_CONFIG_DIR || join(homedir(), '.claude') + return join(claudeHome, 'projects') +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }) + const files: string[] = [] + + for (const entry of entries) { + const fullPath = join(root, entry.name) + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)) + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath) + } + } + + return files + } catch { + return [] + } +} + +function parseJsonLine(line: string): Record | null { + if (line.trim().length === 0) { + return null + } + + try { + const parsed = JSON.parse(line) as unknown + return getRecord(parsed) + } catch { + return null + } +} + +function parseRawClaudeMessage(record: Record): RawJSONLines | null { + const parsed = RawJSONLinesSchema.safeParse(record) + return parsed.success ? parsed.data : null +} + +function findRootSessionId(records: Array<{ lineIndex: number; record: Record }>): string | null { + for (let index = records.length - 1; index >= 0; index -= 1) { + const sessionId = extractSessionIdCandidate(records[index].record) + if (sessionId) { + return sessionId + } + } + + return null +} + +function findRootSessionStartIndex(records: Array<{ lineIndex: number; record: Record }>, rootSessionId: string): number { + const match = records.find((entry) => extractSessionIdCandidate(entry.record) === rootSessionId) + return match?.lineIndex ?? 0 +} + +function extractSessionMeta(record: Record): { + cwd: string | null + timestamp: number | null + explicitTitle: string | null +} { + const payload = getRecord(record.payload) + + const cwd = getString(record.cwd) + ?? getString(payload?.cwd) + + const timestamp = parseTimestamp(record.timestamp) ?? parseTimestamp(payload?.timestamp) + + const explicitTitle = extractExplicitTitleFromRecord(record) ?? extractExplicitTitleFromRecord(payload) + + return { + cwd, + timestamp, + explicitTitle + } +} + +function extractExplicitTitleFromRecord(record: Record | null): string | null { + if (!record) { + return null + } + + const type = getString(record.type) + if (type === 'session_title_change') { + return extractTextValue(record.title ?? record.text) + } + + const payload = getRecord(record.payload) + if (payload) { + const payloadType = getString(payload.type) + if (payloadType === 'session_title_change') { + return extractTextValue(payload.title ?? payload.text) + } + } + + const topLevelTitle = getString(record.title) + if (topLevelTitle) { + return extractTextValue(topLevelTitle) + } + + const payloadTitle = getString(getRecord(record.payload)?.title) + if (payloadTitle) { + return extractTextValue(payloadTitle) + } + + return null +} + +function extractUserPrompt(message: RawJSONLines): string | null { + if (message.type !== 'user') { + return null + } + + return extractUserMessageText(message.message?.content) +} + +function isRealClaudeUserMessage(message: RawJSONLines): message is Extract { + if (message.type !== 'user') { + return false + } + + if (message.isSidechain === true || message.isMeta === true || message.isCompactSummary === true) { + return false + } + + const prompt = extractUserPrompt(message) + if (!prompt) { + return false + } + + const trimmed = prompt.trimStart() + for (const prefix of SYSTEM_INJECTION_PREFIXES) { + if (trimmed.startsWith(prefix)) { + return false + } + } + + return true +} + +function extractTextValue(value: unknown): string | null { + const chunks = extractTextChunks(value) + if (chunks.length === 0) { + return null + } + + return normalizePreviewText(chunks.join(' ')) +} + +function extractUserMessageText(value: unknown): string | null { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value) + return normalized ? normalized : null + } + + if (!Array.isArray(value)) { + return null + } + + const chunks: string[] = [] + for (const entry of value) { + if (!entry || typeof entry !== 'object') { + continue + } + + const item = entry as Record + if (item.type !== 'text') { + continue + } + + const text = getString(item.text) + if (text) { + chunks.push(normalizePreviewText(text)) + } + } + + if (chunks.length === 0) { + return null + } + + return normalizePreviewText(chunks.join(' ')) +} + +function extractSessionIdCandidate(record: Record): string | null { + const payload = getRecord(record.payload) + return getString(record.sessionId) + ?? getString(record.session_id) + ?? getString(payload?.sessionId) + ?? getString(payload?.session_id) + ?? getString(payload?.id) + ?? getString(record.id) +} + +function extractTextChunks(value: unknown): string[] { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value) + return normalized ? [normalized] : [] + } + + if (Array.isArray(value)) { + const chunks: string[] = [] + for (const entry of value) { + chunks.push(...extractTextChunks(entry)) + } + return chunks + } + + const record = getRecord(value) + if (!record) { + return [] + } + + const directKeys = ['title', 'message', 'text', 'content', 'input', 'body'] as const + for (const key of directKeys) { + const entryValue = record[key] + if (entryValue === undefined || entryValue === null) { + continue + } + + const chunks = extractTextChunks(entryValue) + if (chunks.length > 0) { + return chunks + } + } + + return [] +} + +function deriveCwdPreview(cwd: string | null): string | null { + if (!cwd) { + return null + } + + const trimmed = cwd.trim() + if (!trimmed) { + return null + } + + const segment = basename(trimmed) + return segment.length > 0 ? normalizePreviewText(segment) : null +} + +function compareImportableClaudeSessions( + left: ImportableClaudeSessionSummary, + right: ImportableClaudeSessionSummary +): number { + const leftTimestamp = left.timestamp ?? Number.NEGATIVE_INFINITY + const rightTimestamp = right.timestamp ?? Number.NEGATIVE_INFINITY + + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp + } + + return right.transcriptPath.localeCompare(left.transcriptPath) +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Date.parse(value) + return Number.isNaN(parsed) ? null : parsed + } + + return null +} + +function normalizePreviewText(value: string): string { + return value.replace(/\s+/g, ' ').trim() +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null + } + + return value as Record +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index d97dd22304..01e4e6de70 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -20,11 +20,13 @@ export async function createSessionScanner(opts: { sessionId: string | null; workingDirectory: string; onMessage: (message: RawJSONLines) => void; + replayExistingMessages?: boolean; }) { const scanner = new ClaudeSessionScanner({ sessionId: opts.sessionId, workingDirectory: opts.workingDirectory, - onMessage: opts.onMessage + onMessage: opts.onMessage, + replayExistingMessages: opts.replayExistingMessages }); await scanner.start(); @@ -49,12 +51,19 @@ class ClaudeSessionScanner extends BaseSessionScanner { private readonly pendingSessions = new Set(); private currentSessionId: string | null; private readonly scannedSessions = new Set(); - - constructor(opts: { sessionId: string | null; workingDirectory: string; onMessage: (message: RawJSONLines) => void }) { + private readonly replayExistingMessages: boolean; + + constructor(opts: { + sessionId: string | null; + workingDirectory: string; + onMessage: (message: RawJSONLines) => void; + replayExistingMessages?: boolean; + }) { super({ intervalMs: 3000 }); this.projectDir = getProjectPath(opts.workingDirectory); this.onMessage = opts.onMessage; this.currentSessionId = opts.sessionId; + this.replayExistingMessages = opts.replayExistingMessages ?? false; } public onNewSession(sessionId: string): void { @@ -79,7 +88,7 @@ class ClaudeSessionScanner extends BaseSessionScanner { } protected async initialize(): Promise { - if (!this.currentSessionId) { + if (!this.currentSessionId || this.replayExistingMessages) { return; } const sessionFile = this.sessionFilePath(this.currentSessionId); diff --git a/cli/src/codex/codexAppServerClient.test.ts b/cli/src/codex/codexAppServerClient.test.ts new file mode 100644 index 0000000000..9ad435d157 --- /dev/null +++ b/cli/src/codex/codexAppServerClient.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; +import { resolveCodexAppServerCommand } from './codexAppServerClient'; + +describe('resolveCodexAppServerCommand', () => { + it('uses CODEX_BIN when provided', () => { + expect(resolveCodexAppServerCommand({ CODEX_BIN: '/home/user/.npm-global/bin/codex' })).toBe('/home/user/.npm-global/bin/codex'); + }); + + it('falls back to codex when CODEX_BIN is empty', () => { + expect(resolveCodexAppServerCommand({ CODEX_BIN: '' })).toBe('codex'); + }); +}); diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index b45b4976b9..019a0c2828 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -56,6 +56,11 @@ function createAbortError(): Error { return error; } +export function resolveCodexAppServerCommand(env: Record = process.env): string { + const configured = env.CODEX_BIN?.trim(); + return configured && configured.length > 0 ? configured : 'codex'; +} + export class CodexAppServerClient { private process: ChildProcessWithoutNullStreams | null = null; private connected = false; @@ -73,7 +78,8 @@ export class CodexAppServerClient { return; } - this.process = spawn('codex', ['app-server'], { + const command = resolveCodexAppServerCommand(); + this.process = spawn(command, ['app-server'], { env: Object.keys(process.env).reduce((acc, key) => { const value = process.env[key]; if (typeof value === 'string') acc[key] = value; @@ -107,7 +113,7 @@ export class CodexAppServerClient { logger.debug('[CodexAppServer] Process error', error); const message = error instanceof Error ? error.message : String(error); this.rejectAllPending(new Error( - `Failed to spawn codex app-server: ${message}. Is it installed and on PATH?`, + `Failed to spawn Codex app-server with '${command}': ${message}. Set CODEX_BIN or install codex on PATH.`, { cause: error } )); this.connected = false; diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 5e84eb432f..d8ac6dabd2 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -29,6 +29,7 @@ export async function codexLocal(opts: { path: string; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string; sandbox?: 'read-only' | 'workspace-write' | 'danger-full-access'; onSessionFound: (id: string) => void; codexArgs?: string[]; @@ -49,6 +50,10 @@ export async function codexLocal(opts: { args.push('--model-reasoning-effort', opts.modelReasoningEffort); } + if (opts.serviceTier) { + args.push('-c', `service_tier="${opts.serviceTier}"`); + } + if (opts.sandbox) { args.push('--sandbox', opts.sandbox); } diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 98741baba6..26508142ec 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -79,7 +79,9 @@ function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' } }, getPermissionMode: () => permissionMode, + getModel: () => null, getModelReasoningEffort: () => null, + getServiceTier: () => null, onSessionFound: () => {}, sendSessionEvent: (event: { type: string; message?: string }) => { sessionEvents.push(event); diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index e13ae75bb2..8381fe178a 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -43,7 +43,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch await codexLocal({ path: session.path, sessionId: resumeSessionId, + model: session.getModel() ?? undefined, modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, + serviceTier: session.getServiceTier() ?? undefined, onSessionFound: handleSessionFound, abort: abortSignal, codexArgs, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570d..01fa6d7465 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -4,6 +4,7 @@ import type { EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, + turnNotifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], initializeCalls: [] as unknown[] })); @@ -40,6 +41,11 @@ vi.mock('./codexAppServerClient', () => { harness.notifications.push({ method: 'turn/started', params: started }); this.notificationHandler?.('turn/started', started); + for (const notification of harness.turnNotifications) { + harness.notifications.push(notification); + this.notificationHandler?.(notification.method, notification.params); + } + const completed = { status: 'Completed', turn: {} }; harness.notifications.push({ method: 'turn/completed', params: completed }); this.notificationHandler?.('turn/completed', completed); @@ -89,7 +95,9 @@ function createSessionStub() { const codexMessages: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; + const metadataUpdates: unknown[] = []; let currentModel: string | null | undefined; + let metadata: Record = {}; let agentState: FakeAgentState = { requests: {}, completedRequests: {} @@ -111,6 +119,10 @@ function createSessionStub() { sendUserMessage(_text: string) {}, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); + }, + updateMetadata(handler: (metadata: Record) => Record) { + metadata = handler(metadata); + metadataUpdates.push(metadata); } }; @@ -159,13 +171,15 @@ function createSessionStub() { foundSessionIds, rpcHandlers, getModel: () => currentModel, - getAgentState: () => agentState + getAgentState: () => agentState, + getMetadataUpdates: () => metadataUpdates }; } describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; + harness.turnNotifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; }); @@ -198,4 +212,20 @@ describe('codexRemoteLauncher', () => { expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); }); + + it('applies Codex session title changes to metadata immediately', async () => { + harness.turnNotifications = [ + { method: 'thread/title/updated', params: { title: 'Native Codex Title' } } + ]; + const { session, getMetadataUpdates } = createSessionStub(); + + await codexRemoteLauncher(session as never); + + expect(getMetadataUpdates()).toContainEqual({ + summary: { + text: 'Native Codex Title', + updatedAt: expect.any(Number) + } + }); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 53024deece..3cf6df9c31 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -16,6 +16,10 @@ import { AppServerEventConverter } from './utils/appServerEventConverter'; import { registerAppServerPermissionHandlers } from './utils/appServerPermissionAdapter'; import { buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; +import { createCodexSessionScanner } from './utils/codexSessionScanner'; +import { convertCodexEvent } from './utils/codexEventConverter'; +import { resolveCodexSessionFile } from './utils/resolveCodexSessionFile'; +import { resolveCodexSubagentNickname } from './utils/spawnNicknameResolver'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -25,6 +29,17 @@ import { type HappyServer = Awaited>['server']; type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + return value as Record; +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + class CodexRemoteLauncher extends RemoteLauncherBase { private readonly session: CodexSession; private readonly appServerClient: CodexAppServerClient; @@ -35,6 +50,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { private abortController: AbortController = new AbortController(); private currentThreadId: string | null = null; private currentTurnId: string | null = null; + private readonly spawnNicknameBackfillTimers = new Set>(); + private readonly spawnNicknameBackfillCallIds = new Set(); constructor(session: CodexSession) { super(process.env.DEBUG ? session.logPath : undefined); @@ -74,6 +91,65 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } + private scheduleSpawnNicknameBackfill(callId: string, output: unknown): void { + const outputRecord = asRecord(output); + if (!outputRecord || asString(outputRecord.nickname)) { + return; + } + + const agentIds = Array.isArray(outputRecord.agent_ids) ? outputRecord.agent_ids : []; + const agentId = asString(outputRecord.agent_id) + ?? agentIds.find((value: unknown): value is string => typeof value === 'string' && value.length > 0) + ?? null; + if (!agentId || this.spawnNicknameBackfillCallIds.has(callId)) { + return; + } + + this.spawnNicknameBackfillCallIds.add(callId); + let attempts = 0; + const maxAttempts = 20; + + const scheduleAttempt = (delayMs: number) => { + const timer = setTimeout(() => { + this.spawnNicknameBackfillTimers.delete(timer); + void (async () => { + if (this.shouldExit) { + return; + } + + attempts += 1; + const nickname = await resolveCodexSubagentNickname(agentId); + if (nickname) { + this.session.sendAgentMessage({ + type: 'tool-call-result', + callId, + output: { + ...outputRecord, + nickname + }, + id: randomUUID() + }); + return; + } + + if (attempts < maxAttempts) { + scheduleAttempt(500); + return; + } + + this.spawnNicknameBackfillCallIds.delete(callId); + })().catch((error) => { + logger.debug('[Codex] Failed to backfill subagent nickname', error); + this.spawnNicknameBackfillCallIds.delete(callId); + }); + }, delayMs); + timer.unref?.(); + this.spawnNicknameBackfillTimers.add(timer); + }; + + scheduleAttempt(0); + } + private async handleExitFromUi(): Promise { logger.debug('[codex-remote]: Exiting agent via Ctrl-C'); this.exitReason = 'exit'; @@ -117,6 +193,39 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const appServerClient = this.appServerClient; const appServerEventConverter = new AppServerEventConverter(); + const replayExplicitResumeTranscript = async (): Promise => { + const resumeSessionId = session.sessionId; + if (!resumeSessionId) { + return; + } + + const resolvedSessionFile = await resolveCodexSessionFile(resumeSessionId); + if (resolvedSessionFile.status !== 'found') { + logger.debug(`[Codex] No transcript replay available for explicit remote resume ${resumeSessionId} (${resolvedSessionFile.status})`); + return; + } + + const scanner = await createCodexSessionScanner({ + sessionId: resumeSessionId, + cwd: session.path, + startupTimestampMs: Date.now(), + resolvedSessionFile, + onEvent: (event) => { + const converted = convertCodexEvent(event); + if (converted?.sessionId) { + session.onSessionFound(converted.sessionId); + } + if (converted?.userMessage) { + session.sendUserMessage(converted.userMessage, converted.userMessageMeta); + } + if (converted?.message) { + session.sendAgentMessage(converted.message); + } + } + }); + await scanner.cleanup(); + }; + const normalizeCommand = (value: unknown): string | undefined => { if (typeof value === 'string') { const trimmed = value.trim(); @@ -129,15 +238,19 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return undefined; }; - const asRecord = (value: unknown): Record | null => { - if (!value || typeof value !== 'object') { - return null; - } - return value as Record; - }; - - const asString = (value: unknown): string | null => { - return typeof value === 'string' && value.length > 0 ? value : null; + const extractParentToolCallId = (value: unknown): string | null => { + const record = asRecord(value); + if (!record) { + return asString(value); + } + return asString( + record.parent_tool_call_id + ?? record.parentToolCallId + ?? asRecord(record.input)?.parent_tool_call_id + ?? asRecord(record.input)?.parentToolCallId + ?? asRecord(record.output)?.parent_tool_call_id + ?? asRecord(record.output)?.parentToolCallId + ); }; const applyResolvedModel = (value: unknown): string | undefined => { @@ -241,6 +354,24 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let clearReadyAfterTurnTimer: (() => void) | null = null; let turnInFlight = false; let allowAnonymousTerminalEvent = false; + let lastRootSessionTitle: string | null = null; + const toolNameByCallId = new Map(); + + const applyRootSessionTitle = (title: string) => { + if (lastRootSessionTitle === title) { + return; + } + lastRootSessionTitle = title; + session.client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + })); + }; + + await replayExplicitResumeTranscript(); const handleCodexEvent = (msg: Record) => { const msgType = asString(msg.type); @@ -257,6 +388,18 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return; } + if (msgType === 'session_title_change') { + const title = asString(msg.title); + if (title) { + applyRootSessionTitle(title); + } + return; + } + + if (msgType === 'subagent_title_change') { + return; + } + if (msgType === 'task_started') { const turnId = eventTurnId; if (turnId) { @@ -363,10 +506,26 @@ class CodexRemoteLauncher extends RemoteLauncherBase { if (msgType === 'agent_message') { const message = asString(msg.message); if (message) { - session.sendAgentMessage({ + const payload: Record = { type: 'message', message, id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + } + } + if (msgType === 'user_message') { + const message = asString(msg.message); + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (message && parentToolCallId) { + session.sendUserMessage(message, { + isSidechain: true, + sidechainKey: parentToolCallId }); } } @@ -377,14 +536,22 @@ class CodexRemoteLauncher extends RemoteLauncherBase { delete inputs.type; delete inputs.call_id; delete inputs.callId; + delete inputs.parent_tool_call_id; + delete inputs.parentToolCallId; - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call', name: 'CodexBash', callId: callId, input: inputs, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'exec_command_end') { @@ -394,13 +561,21 @@ class CodexRemoteLauncher extends RemoteLauncherBase { delete output.type; delete output.call_id; delete output.callId; + delete output.parent_tool_call_id; + delete output.parentToolCallId; - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call-result', callId: callId, output, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'token_count') { @@ -409,6 +584,46 @@ class CodexRemoteLauncher extends RemoteLauncherBase { id: randomUUID() }); } + if (msgType === 'tool_call') { + const callId = asString(msg.call_id ?? msg.callId); + const name = asString(msg.name); + if (callId && name) { + toolNameByCallId.set(callId, name); + const payload: Record = { + type: 'tool-call', + name, + callId, + input: msg.input ?? {}, + id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + } + } + if (msgType === 'tool_call_result') { + const callId = asString(msg.call_id ?? msg.callId); + if (callId) { + const payload: Record = { + type: 'tool-call-result', + callId, + output: msg.output, + id: randomUUID() + }; + const parentToolCallId = asString(msg.parent_tool_call_id ?? msg.parentToolCallId); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); + if (toolNameByCallId.get(callId) === 'CodexSpawnAgent') { + this.scheduleSpawnNicknameBackfill(callId, msg.output); + } + } + } if (msgType === 'patch_apply_begin') { const callId = asString(msg.call_id ?? msg.callId); if (callId) { @@ -417,7 +632,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const filesMsg = changeCount === 1 ? '1 file' : `${changeCount} files`; messageBuffer.addMessage(`Modifying ${filesMsg}...`, 'tool'); - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call', name: 'CodexPatch', callId: callId, @@ -426,7 +641,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { changes }, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'patch_apply_end') { @@ -444,7 +665,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { messageBuffer.addMessage(`Error: ${errorMsg.substring(0, 200)}`, 'result'); } - session.sendAgentMessage({ + const payload: Record = { type: 'tool-call-result', callId: callId, output: { @@ -453,7 +674,13 @@ class CodexRemoteLauncher extends RemoteLauncherBase { success }, id: randomUUID() - }); + }; + const parentToolCallId = extractParentToolCallId(msg); + if (parentToolCallId) { + payload.isSidechain = true; + payload.parentToolCallId = parentToolCallId; + } + session.sendAgentMessage(payload); } } if (msgType === 'mcp_tool_call_begin') { @@ -755,6 +982,11 @@ class CodexRemoteLauncher extends RemoteLauncherBase { this.permissionHandler?.reset(); this.reasoningProcessor?.abort(); this.diffProcessor?.reset(); + for (const timer of this.spawnNicknameBackfillTimers) { + clearTimeout(timer); + } + this.spawnNicknameBackfillTimers.clear(); + this.spawnNicknameBackfillCallIds.clear(); this.permissionHandler = null; this.reasoningProcessor = null; this.diffProcessor = null; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 223807b1c7..13be95f6cf 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -16,6 +16,7 @@ export interface EnhancedMode { model?: string; collaborationMode: CodexCollaborationMode; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string; } interface LoopOptions { @@ -31,6 +32,7 @@ interface LoopOptions { permissionMode?: PermissionMode; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; onSessionReady?: (session: CodexSession) => void; @@ -56,6 +58,7 @@ export async function loop(opts: LoopOptions): Promise { permissionMode: opts.permissionMode ?? 'default', model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, + serviceTier: opts.serviceTier, collaborationMode: opts.collaborationMode ?? 'default' }); diff --git a/cli/src/codex/runCodex.test.ts b/cli/src/codex/runCodex.test.ts new file mode 100644 index 0000000000..8e7c13daf3 --- /dev/null +++ b/cli/src/codex/runCodex.test.ts @@ -0,0 +1,158 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +type MockCodexSessionState = { + model: string | null | undefined; + modelReasoningEffort: string | null | undefined; + serviceTier: string | null | undefined; + permissionMode: string | undefined; + collaborationMode: string | undefined; +}; + +const mockCodexSession = vi.hoisted(() => { + const state: MockCodexSessionState = { + model: undefined, + modelReasoningEffort: null, + serviceTier: undefined, + permissionMode: undefined, + collaborationMode: undefined + }; + + return { + state, + getModel: vi.fn(() => state.model), + getModelReasoningEffort: vi.fn(() => state.modelReasoningEffort), + getServiceTier: vi.fn(() => state.serviceTier), + getPermissionMode: vi.fn(() => state.permissionMode), + getCollaborationMode: vi.fn(() => state.collaborationMode), + setPermissionMode: vi.fn((value: string) => { + state.permissionMode = value; + }), + setModel: vi.fn((value: string | null) => { + state.model = value; + }), + setModelReasoningEffort: vi.fn((value: string | null) => { + state.modelReasoningEffort = value; + }), + setServiceTier: vi.fn((value: string | null) => { + state.serviceTier = value; + }), + setCollaborationMode: vi.fn((value: string) => { + state.collaborationMode = value; + }), + stopKeepAlive: vi.fn() + }; +}); + +const harness = vi.hoisted(() => ({ + bootstrapArgs: [] as Array>, + loopArgs: [] as Array>, + session: { + onUserMessage: vi.fn(), + rpcHandlerManager: { + registerHandler: vi.fn() + } + } +})); + +vi.mock('@/agent/sessionFactory', () => ({ + bootstrapSession: vi.fn(async (options: Record) => { + harness.bootstrapArgs.push(options); + return { + api: {}, + session: harness.session + }; + }) +})); + +vi.mock('./loop', () => ({ + loop: vi.fn(async (options: Record) => { + harness.loopArgs.push(options); + const onSessionReady = options.onSessionReady as ((session: unknown) => void) | undefined; + onSessionReady?.(mockCodexSession); + }) +})); + +vi.mock('@/claude/registerKillSessionHandler', () => ({ + registerKillSessionHandler: vi.fn() +})); + +vi.mock('@/agent/runnerLifecycle', () => ({ + createModeChangeHandler: vi.fn(() => vi.fn()), + createRunnerLifecycle: vi.fn(() => ({ + registerProcessHandlers: vi.fn(), + cleanupAndExit: vi.fn(async () => {}), + markCrash: vi.fn(), + setExitCode: vi.fn(), + setArchiveReason: vi.fn() + })), + setControlledByUser: vi.fn() +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: vi.fn() + } +})); + +vi.mock('@/utils/attachmentFormatter', () => ({ + formatMessageWithAttachments: vi.fn((text: string) => text) +})); + +vi.mock('@/utils/invokedCwd', () => ({ + getInvokedCwd: vi.fn(() => '/tmp/project') +})); + +import { runCodex } from './runCodex'; + +describe('runCodex', () => { + beforeEach(() => { + harness.bootstrapArgs.length = 0; + harness.loopArgs.length = 0; + harness.session.onUserMessage.mockReset(); + harness.session.rpcHandlerManager.registerHandler.mockReset(); + mockCodexSession.state.model = undefined; + mockCodexSession.state.modelReasoningEffort = null; + mockCodexSession.state.serviceTier = undefined; + mockCodexSession.state.permissionMode = undefined; + mockCodexSession.state.collaborationMode = undefined; + mockCodexSession.getModel.mockClear(); + mockCodexSession.getModelReasoningEffort.mockClear(); + mockCodexSession.getServiceTier.mockClear(); + mockCodexSession.getPermissionMode.mockClear(); + mockCodexSession.getCollaborationMode.mockClear(); + mockCodexSession.setPermissionMode.mockClear(); + mockCodexSession.setModel.mockClear(); + mockCodexSession.setModelReasoningEffort.mockClear(); + mockCodexSession.setServiceTier.mockClear(); + mockCodexSession.setCollaborationMode.mockClear(); + mockCodexSession.stopKeepAlive.mockClear(); + }); + + it('applies explicit reasoning effort and service tier without reading stale wrapper values', async () => { + await runCodex({}); + + const configHandler = harness.session.rpcHandlerManager.registerHandler.mock.calls.find( + (call: unknown[]) => call[0] === 'set-session-config' + ); + expect(configHandler).toBeDefined(); + + const handler = configHandler![1] as (payload: unknown) => Promise; + const result = await handler({ + modelReasoningEffort: 'xhigh', + serviceTier: 'fast' + }) as Record; + + expect(result).toEqual({ + applied: { + permissionMode: 'default', + model: null, + modelReasoningEffort: 'xhigh', + effort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default' + } + }); + expect(mockCodexSession.setModelReasoningEffort).toHaveBeenLastCalledWith('xhigh'); + expect(mockCodexSession.setServiceTier).toHaveBeenLastCalledWith('fast'); + }); +}); diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d4f3496515..73e035d4c2 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -25,6 +25,7 @@ export async function runCodex(opts: { resumeSessionId?: string; model?: string; modelReasoningEffort?: ReasoningEffort; + serviceTier?: string; }): Promise { const workingDirectory = getInvokedCwd(); const startedBy = opts.startedBy ?? 'terminal'; @@ -40,7 +41,8 @@ export async function runCodex(opts: { workingDirectory, agentState: state, model: opts.model, - modelReasoningEffort: opts.modelReasoningEffort + modelReasoningEffort: opts.modelReasoningEffort, + serviceTier: opts.serviceTier }); const startingMode: 'local' | 'remote' = startedBy === 'runner' ? 'remote' : 'local'; @@ -51,6 +53,7 @@ export async function runCodex(opts: { permissionMode: mode.permissionMode, model: mode.model, modelReasoningEffort: mode.modelReasoningEffort, + serviceTier: mode.serviceTier, collaborationMode: mode.collaborationMode })); @@ -60,6 +63,7 @@ export async function runCodex(opts: { let currentPermissionMode: PermissionMode = opts.permissionMode ?? 'default'; let currentModel = opts.model; let currentModelReasoningEffort: ReasoningEffort | undefined = opts.modelReasoningEffort; + let currentServiceTier: string | undefined = opts.serviceTier; let currentCollaborationMode: EnhancedMode['collaborationMode'] = 'default'; const lifecycle = createRunnerLifecycle({ @@ -71,27 +75,30 @@ export async function runCodex(opts: { lifecycle.registerProcessHandlers(); registerKillSessionHandler(session.rpcHandlerManager, lifecycle.cleanupAndExit); - const syncSessionMode = () => { + const syncSessionMode = (options?: { readRuntimeFromSession?: boolean }) => { const sessionInstance = sessionWrapperRef.current; if (!sessionInstance) { return; } - const sessionModel = sessionInstance.getModel(); - if (sessionModel !== undefined) { - currentModel = sessionModel ?? undefined; - } - const sessionModelReasoningEffort = sessionInstance.getModelReasoningEffort(); - if (sessionModelReasoningEffort !== undefined) { - currentModelReasoningEffort = (sessionModelReasoningEffort ?? undefined) as ReasoningEffort | undefined; + if (options?.readRuntimeFromSession !== false) { + const sessionModel = sessionInstance.getModel(); + if (sessionModel !== undefined) { + currentModel = sessionModel ?? undefined; + } + const sessionModelReasoningEffort = sessionInstance.getModelReasoningEffort(); + if (sessionModelReasoningEffort !== undefined) { + currentModelReasoningEffort = (sessionModelReasoningEffort ?? undefined) as ReasoningEffort | undefined; + } } sessionInstance.setPermissionMode(currentPermissionMode); sessionInstance.setModel(currentModel ?? null); sessionInstance.setModelReasoningEffort(currentModelReasoningEffort ?? null); + sessionInstance.setServiceTier(currentServiceTier ?? null); sessionInstance.setCollaborationMode(currentCollaborationMode); logger.debug( `[Codex] Synced session config for keepalive: ` + `permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, ` + - `modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, collaborationMode=${currentCollaborationMode}` + `modelReasoningEffort=${currentModelReasoningEffort ?? 'default'}, serviceTier=${currentServiceTier ?? 'default'}, collaborationMode=${currentCollaborationMode}` ); }; @@ -108,6 +115,10 @@ export async function runCodex(opts: { if (sessionModelReasoningEffort !== undefined) { currentModelReasoningEffort = (sessionModelReasoningEffort ?? undefined) as ReasoningEffort | undefined; } + const sessionServiceTier = sessionWrapperRef.current?.getServiceTier(); + if (sessionServiceTier !== undefined) { + currentServiceTier = sessionServiceTier ?? undefined; + } const sessionCollaborationMode = sessionWrapperRef.current?.getCollaborationMode(); if (sessionCollaborationMode) { currentCollaborationMode = sessionCollaborationMode; @@ -117,13 +128,14 @@ export async function runCodex(opts: { logger.debug( `[Codex] User message received with permission mode: ${currentPermissionMode}, ` + `model: ${currentModel ?? 'auto'}, modelReasoningEffort: ${currentModelReasoningEffort ?? 'default'}, ` + - `collaborationMode: ${currentCollaborationMode}` + `serviceTier: ${currentServiceTier ?? 'default'}, collaborationMode: ${currentCollaborationMode}` ); const enhancedMode: EnhancedMode = { permissionMode: messagePermissionMode ?? 'default', model: currentModel, modelReasoningEffort: currentModelReasoningEffort, + serviceTier: currentServiceTier, collaborationMode: currentCollaborationMode }; const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); @@ -167,29 +179,69 @@ export async function runCodex(opts: { return value as ReasoningEffort; }; + const resolveModel = (value: unknown): string | undefined => { + if (value === null) { + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid model'); + } + return value.trim(); + }; + + const resolveServiceTier = (value: unknown): string | undefined => { + if (value === null) { + return undefined; + } + if (typeof value !== 'string' || value.trim().length === 0) { + throw new Error('Invalid service tier'); + } + return value.trim(); + }; + session.rpcHandlerManager.registerHandler('set-session-config', async (payload: unknown) => { if (!payload || typeof payload !== 'object') { throw new Error('Invalid session config payload'); } - const config = payload as { permissionMode?: unknown; modelReasoningEffort?: unknown; collaborationMode?: unknown }; + const config = payload as { + permissionMode?: unknown; + model?: unknown; + modelReasoningEffort?: unknown; + effort?: unknown; + serviceTier?: unknown; + collaborationMode?: unknown; + }; if (config.permissionMode !== undefined) { currentPermissionMode = resolvePermissionMode(config.permissionMode); } + if (config.model !== undefined) { + currentModel = resolveModel(config.model); + } + + if (config.serviceTier !== undefined) { + currentServiceTier = resolveServiceTier(config.serviceTier); + } + if (config.modelReasoningEffort !== undefined) { currentModelReasoningEffort = resolveModelReasoningEffort(config.modelReasoningEffort); + } else if (config.effort !== undefined) { + currentModelReasoningEffort = resolveModelReasoningEffort(config.effort); } if (config.collaborationMode !== undefined) { currentCollaborationMode = resolveCollaborationMode(config.collaborationMode); } - syncSessionMode(); + syncSessionMode({ readRuntimeFromSession: false }); return { applied: { permissionMode: currentPermissionMode, + model: currentModel ?? null, modelReasoningEffort: currentModelReasoningEffort ?? null, + effort: currentModelReasoningEffort ?? null, + serviceTier: currentServiceTier ?? null, collaborationMode: currentCollaborationMode } }; @@ -208,6 +260,7 @@ export async function runCodex(opts: { permissionMode: currentPermissionMode, model: currentModel, modelReasoningEffort: currentModelReasoningEffort, + serviceTier: currentServiceTier, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, onModeChange: createModeChangeHandler(session), diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index c490d748ee..a0e160d1a7 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -4,7 +4,7 @@ import { AgentSessionBase } from '@/agent/sessionBase'; import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; -import type { SessionModel, SessionModelReasoningEffort } from '@/api/types'; +import type { SessionModel, SessionModelReasoningEffort, SessionServiceTier } from '@/api/types'; type LocalLaunchFailure = { message: string; @@ -34,6 +34,7 @@ export class CodexSession extends AgentSessionBase { permissionMode?: PermissionMode; model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; + serviceTier?: SessionServiceTier; collaborationMode?: EnhancedMode['collaborationMode']; }) { super({ @@ -54,6 +55,7 @@ export class CodexSession extends AgentSessionBase { permissionMode: opts.permissionMode, model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, + serviceTier: opts.serviceTier, collaborationMode: opts.collaborationMode }); @@ -64,6 +66,7 @@ export class CodexSession extends AgentSessionBase { this.permissionMode = opts.permissionMode; this.model = opts.model; this.modelReasoningEffort = opts.modelReasoningEffort; + this.serviceTier = opts.serviceTier; this.collaborationMode = opts.collaborationMode; } @@ -79,6 +82,10 @@ export class CodexSession extends AgentSessionBase { this.modelReasoningEffort = modelReasoningEffort; }; + setServiceTier = (serviceTier: SessionServiceTier): void => { + this.serviceTier = serviceTier; + }; + setCollaborationMode = (mode: EnhancedMode['collaborationMode']): void => { this.collaborationMode = mode; }; @@ -91,8 +98,8 @@ export class CodexSession extends AgentSessionBase { this.client.sendAgentMessage(message); }; - sendUserMessage = (text: string): void => { - this.client.sendUserMessage(text); + sendUserMessage = (text: string, meta?: Parameters[1]): void => { + this.client.sendUserMessage(text, meta); }; sendSessionEvent = (event: Parameters[0]): void => { diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909f8..fae09a3ce9 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -86,7 +86,8 @@ export function buildThreadStartParams(args: { const configWithInstructions = { ...config, developer_instructions: resolvedDeveloperInstructions, - ...(args.mode.modelReasoningEffort ? { model_reasoning_effort: args.mode.modelReasoningEffort } : {}) + ...(args.mode.modelReasoningEffort ? { model_reasoning_effort: args.mode.modelReasoningEffort } : {}), + ...(args.mode.serviceTier ? { service_tier: args.mode.serviceTier } : {}) }; const params: ThreadStartParams = { diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 272769260b..cf1c31df57 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -16,6 +16,34 @@ describe('AppServerEventConverter', () => { expect(events).toEqual([{ type: 'thread_started', thread_id: 'thread-2' }]); }); + it('maps root thread title updates to session title changes', () => { + const converter = new AppServerEventConverter(); + + expect(converter.handleNotification('thread/title/updated', { title: 'Native Codex Title' })).toEqual([ + { type: 'session_title_change', title: 'Native Codex Title' } + ]); + + expect(converter.handleNotification('thread/updated', { thread: { id: 'thread-1', title: 'Refined Codex Title' } })).toEqual([ + { type: 'session_title_change', title: 'Refined Codex Title' } + ]); + }); + + it('does not map child thread title updates onto the root session', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['agent-1'], + nickname: 'Raman' + } + }); + + expect(converter.handleNotification('thread/title/updated', { threadId: 'agent-1', title: 'Child title' })).toEqual([]); + }); + it('maps turn/started and completed statuses', () => { const converter = new AppServerEventConverter(); @@ -85,6 +113,82 @@ describe('AppServerEventConverter', () => { }]); }); + it('maps Codex collab spawn/wait items and links child thread events to the spawn call', () => { + const converter = new AppServerEventConverter(); + + const spawnStarted = converter.handleNotification('item/started', { + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + prompt: 'First child prompt', + model: 'gpt-5.4', + reasoningEffort: 'xhigh' + } + }); + expect(spawnStarted).toEqual([{ + type: 'tool_call', + call_id: 'spawn-1', + name: 'CodexSpawnAgent', + input: { + message: 'First child prompt', + model: 'gpt-5.4', + reasoningEffort: 'xhigh' + } + }]); + + const spawnCompleted = converter.handleNotification('item/completed', { + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['agent-1'], + nickname: 'Raman' + } + }); + expect(spawnCompleted).toEqual([{ + type: 'tool_call_result', + call_id: 'spawn-1', + output: { + agent_id: 'agent-1', + agent_ids: ['agent-1'], + nickname: 'Raman', + agentsStates: undefined + } + }]); + + const childMessage = converter.handleNotification('item/completed', { + threadId: 'agent-1', + item: { + id: 'child-msg-1', + type: 'agentMessage', + text: 'child answer' + } + }); + expect(childMessage).toEqual([{ + type: 'agent_message', + message: 'child answer', + parent_tool_call_id: 'spawn-1' + }]); + + const waitStarted = converter.handleNotification('item/started', { + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['agent-1'] + } + }); + expect(waitStarted).toEqual([{ + type: 'tool_call', + call_id: 'wait-1', + name: 'CodexWaitAgent', + input: { + targets: ['agent-1'] + } + }]); + }); + it('maps reasoning deltas', () => { const converter = new AppServerEventConverter(); diff --git a/cli/src/codex/utils/appServerEventConverter.ts b/cli/src/codex/utils/appServerEventConverter.ts index 08a957630f..ee5167ef7e 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -123,6 +123,53 @@ function extractReasoningText(item: Record): string | null { return null; } +function extractThreadId(params: Record): string | null { + const thread = asRecord(params.thread); + return asString( + params.threadId + ?? params.thread_id + ?? thread?.threadId + ?? thread?.thread_id + ?? thread?.id + ); +} + +function normalizeTitle(value: unknown): string | null { + if (typeof value !== 'string') { + return null; + } + const title = value.trim(); + if (!title || title.toLowerCase() === 'none') { + return null; + } + return title; +} + +function extractThreadTitle(params: Record): string | null { + const thread = asRecord(params.thread); + const candidates = [ + params.title, + params.name, + params.summaryTitle, + params.summary_title, + params.summary, + thread?.title, + thread?.name, + thread?.summaryTitle, + thread?.summary_title, + thread?.summary + ]; + + for (const candidate of candidates) { + const title = normalizeTitle(candidate); + if (title) { + return title; + } + } + + return null; +} + export class AppServerEventConverter { private readonly agentMessageBuffers = new Map(); private readonly reasoningBuffers = new Map(); @@ -135,6 +182,27 @@ export class AppServerEventConverter { private readonly lastAgentMessageDeltaByItemId = new Map(); private readonly lastReasoningDeltaByItemId = new Map(); private readonly lastCommandOutputDeltaByItemId = new Map(); + private readonly childThreadIdToParentToolCallId = new Map(); + private readonly lastDeliveredChildAgentMessageByThreadId = new Map(); + + private addSidechainMeta( + event: ConvertedEvent, + threadId: string | null + ): ConvertedEvent { + if (!threadId) { + return event; + } + + const parentToolCallId = this.childThreadIdToParentToolCallId.get(threadId); + if (!parentToolCallId) { + return event; + } + + return { + ...event, + parent_tool_call_id: parentToolCallId + }; + } private handleWrappedCodexEvent(paramsRecord: Record): ConvertedEvent[] | null { const msg = asRecord(paramsRecord.msg); @@ -147,6 +215,20 @@ export class AppServerEventConverter { return []; } + if (msgType === 'session_title_change') { + const title = extractThreadTitle(msg); + return title ? [{ type: 'session_title_change', title }] : []; + } + + if ( + msgType === 'thread_title_updated' || + msgType === 'thread_title_change' || + msgType === 'thread_updated' || + msgType === 'session_title_updated' + ) { + return this.handleThreadTitleUpdate(msg); + } + if (msgType === 'item_started' || msgType === 'item_completed') { const itemMethod = msgType === 'item_started' ? 'item/started' : 'item/completed'; const item = asRecord(msg.item) ?? {}; @@ -165,6 +247,7 @@ export class AppServerEventConverter { msgType === 'turn_aborted' || msgType === 'task_failed' ) { + const threadId = asString(msg.thread_id ?? msg.threadId); const turnId = asString(msg.turn_id ?? msg.turnId); if ((msgType === 'task_complete' || msgType === 'turn_aborted' || msgType === 'task_failed') && !turnId) { logger.debug('[AppServerEventConverter] Ignoring wrapped terminal event without turn_id', { msgType }); @@ -181,7 +264,7 @@ export class AppServerEventConverter { event.error = error; } } - return [event]; + return [this.addSidechainMeta(event, threadId)]; } if (msgType === 'agent_message_delta' || msgType === 'agent_message_content_delta') { @@ -245,6 +328,16 @@ export class AppServerEventConverter { return [msg as ConvertedEvent]; } + private handleThreadTitleUpdate(paramsRecord: Record): ConvertedEvent[] { + const threadId = extractThreadId(paramsRecord); + if (threadId && this.childThreadIdToParentToolCallId.has(threadId)) { + return []; + } + + const title = extractThreadTitle(paramsRecord); + return title ? [{ type: 'session_title_change', title }] : []; + } + handleNotification(method: string, params: unknown): ConvertedEvent[] { const events: ConvertedEvent[] = []; const paramsRecord = asRecord(params) ?? {}; @@ -266,6 +359,15 @@ export class AppServerEventConverter { return events; } + if ( + method === 'thread/title/updated' || + method === 'thread/updated' || + method === 'thread/renamed' || + method === 'session/title/updated' + ) { + return this.handleThreadTitleUpdate(paramsRecord); + } + if (method === 'turn/started') { const turn = asRecord(paramsRecord.turn) ?? paramsRecord; const turnId = asString(turn.turnId ?? turn.turn_id ?? turn.id); @@ -384,11 +486,151 @@ export class AppServerEventConverter { const itemType = normalizeItemType(item.type ?? item.itemType ?? item.kind); const itemId = extractItemId(paramsRecord) ?? asString(item.id ?? item.itemId ?? item.item_id); + const threadId = asString( + paramsRecord.threadId + ?? paramsRecord.thread_id + ?? item.threadId + ?? item.thread_id + ?? asRecord(item.thread)?.id + ?? asRecord(item.thread)?.threadId + ?? asRecord(item.thread)?.thread_id + ); if (!itemType || !itemId) { return events; } + if (itemType === 'collabagenttoolcall') { + const tool = normalizeItemType(item.tool); + if (tool === 'spawnagent') { + if (method === 'item/started') { + events.push({ + type: 'tool_call', + call_id: itemId, + name: 'CodexSpawnAgent', + input: { + message: asString(item.prompt), + model: asString(item.model), + reasoningEffort: asString(item.reasoningEffort ?? item.reasoning_effort) + } + }); + } + + if (method === 'item/completed') { + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string' && value.length > 0) + : []; + const nickname = asString(item.nickname ?? item.agentNickname ?? item.agent_nickname); + for (const receiverThreadId of receiverThreadIds) { + this.childThreadIdToParentToolCallId.set(receiverThreadId, itemId); + } + + events.push({ + type: 'tool_call_result', + call_id: itemId, + output: { + agent_id: receiverThreadIds[0] ?? null, + agent_ids: receiverThreadIds, + ...(nickname ? { nickname } : {}), + agentsStates: item.agentsStates + } + }); + } + + return events; + } + + if (tool === 'wait') { + const receiverThreadIds = Array.isArray(item.receiverThreadIds) + ? item.receiverThreadIds.filter((value): value is string => typeof value === 'string' && value.length > 0) + : []; + + if (method === 'item/started') { + events.push({ + type: 'tool_call', + call_id: itemId, + name: 'CodexWaitAgent', + input: { + targets: receiverThreadIds + } + }); + } + + if (method === 'item/completed') { + const agentsStates = asRecord(item.agentsStates) ?? {}; + const statuses: Record = {}; + for (const [receiverThreadId, rawState] of Object.entries(agentsStates)) { + const stateRecord = asRecord(rawState) ?? {}; + const status = asString(stateRecord.status) ?? 'completed'; + const message = asString(stateRecord.message); + statuses[receiverThreadId] = message ? { status, message } : { status }; + const parentToolCallId = this.childThreadIdToParentToolCallId.get(receiverThreadId); + const lastDeliveredMessage = this.lastDeliveredChildAgentMessageByThreadId.get(receiverThreadId); + if (message && parentToolCallId && lastDeliveredMessage !== message) { + this.lastDeliveredChildAgentMessageByThreadId.set(receiverThreadId, message); + events.push({ + type: 'agent_message', + message, + parent_tool_call_id: parentToolCallId + }); + } + } + + events.push({ + type: 'tool_call_result', + call_id: itemId, + output: { + statuses + } + }); + } + + return events; + } + } + + if (itemType === 'usermessage') { + if (method === 'item/completed') { + const text = extractItemText(item); + const parentToolCallId = threadId ? this.childThreadIdToParentToolCallId.get(threadId) : null; + if (text && parentToolCallId) { + events.push({ + type: 'user_message', + message: text, + parent_tool_call_id: parentToolCallId + }); + } + } + return events; + } + + if (itemType === 'mcptoolcall') { + const server = asString(item.server); + const tool = asString(item.tool); + const parentToolCallId = threadId ? this.childThreadIdToParentToolCallId.get(threadId) : null; + const title = asString(asRecord(item.arguments)?.title); + + if (server === 'hapi' && tool === 'change_title') { + if (parentToolCallId) { + if (title) { + events.push(this.addSidechainMeta({ + type: 'subagent_title_change', + title + }, threadId)); + } + return events; + } + + if (title) { + events.push({ + type: 'session_title_change', + title + }); + } + return events; + } + } + if (itemType === 'agentmessage') { if (method === 'item/completed') { if (this.completedAgentMessageItems.has(itemId)) { @@ -396,7 +638,16 @@ export class AppServerEventConverter { } const text = extractItemText(item) ?? this.agentMessageBuffers.get(itemId); if (text) { - events.push({ type: 'agent_message', message: text }); + const event = this.addSidechainMeta({ type: 'agent_message', message: text }, threadId); + const parentToolCallId = asString((event as Record).parent_tool_call_id); + if (threadId && parentToolCallId) { + const lastDeliveredMessage = this.lastDeliveredChildAgentMessageByThreadId.get(threadId); + if (lastDeliveredMessage === text) { + return events; + } + this.lastDeliveredChildAgentMessageByThreadId.set(threadId, text); + } + events.push(event); this.completedAgentMessageItems.add(itemId); this.agentMessageBuffers.delete(itemId); } @@ -412,7 +663,7 @@ export class AppServerEventConverter { } const text = extractReasoningText(item) ?? this.reasoningBuffers.get(itemId); if (text) { - events.push({ type: 'agent_reasoning', text }); + events.push(this.addSidechainMeta({ type: 'agent_reasoning', text }, threadId)); this.completedReasoningItems.add(itemId); this.reasoningBuffers.delete(itemId); } @@ -432,11 +683,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.commandMeta.set(itemId, meta); - events.push({ + events.push(this.addSidechainMeta({ type: 'exec_command_begin', call_id: itemId, ...meta - }); + }, threadId)); } if (method === 'item/completed') { @@ -447,7 +698,7 @@ export class AppServerEventConverter { const exitCode = asNumber(item.exitCode ?? item.exit_code ?? item.exitcode); const status = asString(item.status); - events.push({ + events.push(this.addSidechainMeta({ type: 'exec_command_end', call_id: itemId, ...meta, @@ -456,7 +707,7 @@ export class AppServerEventConverter { ...(error ? { error } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), ...(status ? { status } : {}) - }); + }, threadId)); this.commandMeta.delete(itemId); this.commandOutputBuffers.delete(itemId); @@ -475,11 +726,11 @@ export class AppServerEventConverter { if (autoApproved !== null) meta.auto_approved = autoApproved; this.fileChangeMeta.set(itemId, meta); - events.push({ + events.push(this.addSidechainMeta({ type: 'patch_apply_begin', call_id: itemId, ...meta - }); + }, threadId)); } if (method === 'item/completed') { @@ -488,14 +739,14 @@ export class AppServerEventConverter { const stderr = asString(item.stderr); const success = asBoolean(item.success ?? item.ok ?? item.applied ?? item.status === 'completed'); - events.push({ + events.push(this.addSidechainMeta({ type: 'patch_apply_end', call_id: itemId, ...meta, ...(stdout ? { stdout } : {}), ...(stderr ? { stderr } : {}), success: success ?? false - }); + }, threadId)); this.fileChangeMeta.delete(itemId); } @@ -520,5 +771,7 @@ export class AppServerEventConverter { this.lastAgentMessageDeltaByItemId.clear(); this.lastReasoningDeltaByItemId.clear(); this.lastCommandOutputDeltaByItemId.clear(); + this.childThreadIdToParentToolCallId.clear(); + this.lastDeliveredChildAgentMessageByThreadId.clear(); } } diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 3abf77763a..2e5029ca34 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -75,6 +75,56 @@ describe('convertCodexEvent', () => { }); }); + it.each([ + ['exec_command', 'CodexBash'], + ['write_stdin', 'CodexWriteStdin'], + ['spawn_agent', 'CodexSpawnAgent'], + ['wait_agent', 'CodexWaitAgent'], + ['send_input', 'CodexSendInput'], + ['close_agent', 'CodexCloseAgent'] + ])('normalizes Codex tool %s as %s', (rawName, expectedName) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: rawName, + call_id: 'call-1', + arguments: '{"message":"child prompt"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: expectedName, + callId: 'call-1' + }); + }); + + it('adds normalized subagent metadata for Codex spawn_agent calls', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: 'spawn-1', + arguments: '{"message":"Summarize this file"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: 'CodexSpawnAgent', + callId: 'spawn-1', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'spawn-1', + prompt: 'Summarize this file' + } + } + }); + }); + it('converts function_call_output items', () => { const result = convertCodexEvent({ type: 'response_item', diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 24ecfd241f..72ed299bca 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -5,43 +5,80 @@ import { logger } from '@/ui/logger'; const CodexSessionEventSchema = z.object({ timestamp: z.string().optional(), type: z.string(), - payload: z.unknown().optional() + payload: z.unknown().optional(), + hapiSidechain: z.object({ + parentToolCallId: z.string() + }).optional() }); export type CodexSessionEvent = z.infer; +type CodexSidechainMeta = { + parentToolCallId: string; +}; + +type CodexMessageMeta = { + subagent?: { + kind: 'spawn'; + sidechainKey: string; + prompt?: string; + }; +}; + export type CodexMessage = { type: 'message'; message: string; id: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'reasoning'; message: string; id: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'reasoning-delta'; delta: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'token_count'; info: Record; id: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'tool-call'; name: string; callId: string; input: unknown; id: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; } | { type: 'tool-call-result'; callId: string; output: unknown; id: string; + meta?: CodexMessageMeta; + isSidechain?: true; + parentToolCallId?: string; }; export type CodexConversionResult = { sessionId?: string; message?: CodexMessage; userMessage?: string; + userMessageMeta?: { + isSidechain: true; + sidechainKey: string; + }; }; function asRecord(value: unknown): Record | null { @@ -72,6 +109,38 @@ function parseArguments(value: unknown): unknown { return value; } +function extractSpawnPrompt(input: unknown): string | undefined { + const record = asRecord(input); + if (!record) { + return undefined; + } + + return asString(record.message) + ?? asString(record.prompt) + ?? asString(record.text) + ?? asString(record.content) + ?? undefined; +} + +function normalizeCodexToolName(name: string): string { + switch (name) { + case 'exec_command': + return 'CodexBash'; + case 'write_stdin': + return 'CodexWriteStdin'; + case 'spawn_agent': + return 'CodexSpawnAgent'; + case 'wait_agent': + return 'CodexWaitAgent'; + case 'send_input': + return 'CodexSendInput'; + case 'close_agent': + return 'CodexCloseAgent'; + default: + return name; + } +} + function extractCallId(payload: Record): string | null { const candidates = [ 'call_id', @@ -91,6 +160,22 @@ function extractCallId(payload: Record): string | null { return null; } +function getSidechainMeta(rawEvent: z.infer): CodexSidechainMeta | null { + return rawEvent.hapiSidechain ?? null; +} + +function applySidechainMeta(message: T, sidechainMeta: CodexSidechainMeta | null): T { + if (!sidechainMeta) { + return message; + } + + return { + ...message, + isSidechain: true, + parentToolCallId: sidechainMeta.parentToolCallId + }; +} + export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | null { const parsed = CodexSessionEventSchema.safeParse(rawEvent); if (!parsed.success) { @@ -99,6 +184,7 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu const { type, payload } = parsed.data; const payloadRecord = asRecord(payload); + const sidechainMeta = getSidechainMeta(parsed.data); if (type === 'session_meta') { const sessionId = payloadRecord ? asString(payloadRecord.id) : null; @@ -125,9 +211,16 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu if (!message) { return null; } - return { + const result: CodexConversionResult = { userMessage: message }; + if (sidechainMeta) { + result.userMessageMeta = { + isSidechain: true, + sidechainKey: sidechainMeta.parentToolCallId + }; + } + return result; } if (eventType === 'agent_message') { @@ -136,11 +229,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'message', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -150,11 +243,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -164,10 +257,10 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning-delta', delta - } + }, sidechainMeta) }; } @@ -177,11 +270,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'token_count', info, id: randomUUID() - } + }, sidechainMeta) }; } @@ -200,14 +293,26 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu if (!name || !callId) { return null; } + const input = parseArguments(payloadRecord.arguments); return { - message: { + message: applySidechainMeta({ type: 'tool-call', - name, + name: normalizeCodexToolName(name), callId, - input: parseArguments(payloadRecord.arguments), - id: randomUUID() - } + input, + id: randomUUID(), + ...(name === 'spawn_agent' + ? { + meta: { + subagent: { + kind: 'spawn', + sidechainKey: callId, + ...(extractSpawnPrompt(input) ? { prompt: extractSpawnPrompt(input) } : {}) + } + } + } + : {}) + }, sidechainMeta) }; } @@ -217,12 +322,12 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'tool-call-result', callId, output: payloadRecord.output, id: randomUUID() - } + }, sidechainMeta) }; } diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index fd8f45b0aa..3a25f8468e 100644 --- a/cli/src/codex/utils/codexSessionScanner.ts +++ b/cli/src/codex/utils/codexSessionScanner.ts @@ -3,6 +3,7 @@ import { logger } from "@/ui/logger"; import { join, relative, resolve, sep } from "node:path"; import { homedir } from "node:os"; import { readFile, readdir, stat } from "node:fs/promises"; +import type { ResolveCodexSessionFileResult } from "./resolveCodexSessionFile"; import type { CodexSessionEvent } from "./codexEventConverter"; interface CodexSessionScannerOptions { @@ -10,6 +11,7 @@ interface CodexSessionScannerOptions { onEvent: (event: CodexSessionEvent) => void; onSessionFound?: (sessionId: string) => void; onSessionMatchFailed?: (message: string) => void; + resolvedSessionFile?: ResolveCodexSessionFileResult | null; cwd?: string; startupTimestampMs?: number; sessionStartWindowMs?: number; @@ -21,7 +23,7 @@ interface CodexSessionScanner { } type PendingEvents = { - events: CodexSessionEvent[]; + entries: SessionFileScanEntry[]; fileSessionId: string | null; }; @@ -34,6 +36,31 @@ const DEFAULT_SESSION_START_WINDOW_MS = 2 * 60 * 1000; export async function createCodexSessionScanner(opts: CodexSessionScannerOptions): Promise { const targetCwd = opts.cwd && opts.cwd.trim().length > 0 ? normalizePath(opts.cwd) : null; + const resolvedSessionFile = opts.resolvedSessionFile ?? null; + + if (resolvedSessionFile) { + if (resolvedSessionFile.status !== 'found') { + const message = `Explicit Codex session resolution failed with status ${resolvedSessionFile.status}; refusing fallback.`; + logger.warn(`[CODEX_SESSION_SCANNER] ${message}`); + opts.onSessionMatchFailed?.(message); + return { + cleanup: async () => {}, + onNewSession: () => {} + }; + } + + const scanner = new CodexSessionScannerImpl(opts, targetCwd, resolvedSessionFile.filePath); + await scanner.start(); + + return { + cleanup: async () => { + await scanner.cleanup(); + }, + onNewSession: (sessionId: string) => { + scanner.onNewSession(sessionId); + } + }; + } if (!targetCwd && !opts.sessionId) { const message = 'No cwd provided for Codex session matching; refusing to fallback.'; @@ -66,14 +93,24 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly sessionIdByFile = new Map(); private readonly sessionCwdByFile = new Map(); private readonly sessionTimestampByFile = new Map(); + private readonly eventOwnerSessionIdByFile = new Map>(); + private readonly currentSegmentOwnerByFile = new Map(); + private readonly inSessionMetaBlockByFile = new Map(); private readonly pendingEventsByFile = new Map(); private readonly sessionMetaParsed = new Set(); private readonly fileEpochByPath = new Map(); + private readonly toolNameByCallId = new Map(); + private readonly linkedChildFilePaths = new Set(); + private readonly linkedChildParentCallIdByFile = new Map(); + private readonly childTranscriptStartLineByFile = new Map(); + private readonly pendingChildSessionIdToParentCallId = new Map(); private readonly targetCwd: string | null; private readonly referenceTimestampMs: number; private readonly sessionStartWindowMs: number; private readonly matchDeadlineMs: number; private readonly sessionDatePrefixes: Set | null; + private readonly explicitResolvedFilePath: string | null; + private readonly explicitResumeMode: boolean; private activeSessionId: string | null; private reportedSessionId: string | null; @@ -84,7 +121,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { private readonly firstRecentActivitySessionIds = new Set(); private loggedAmbiguousRecentActivity = false; - constructor(opts: CodexSessionScannerOptions, targetCwd: string | null) { + constructor(opts: CodexSessionScannerOptions, targetCwd: string | null, explicitResolvedFilePath: string | null = null) { super({ intervalMs: 2000 }); const codexHomeDir = process.env.CODEX_HOME || join(homedir(), '.codex'); this.sessionsRoot = join(codexHomeDir, 'sessions'); @@ -97,14 +134,19 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.referenceTimestampMs = opts.startupTimestampMs ?? Date.now(); this.sessionStartWindowMs = opts.sessionStartWindowMs ?? DEFAULT_SESSION_START_WINDOW_MS; this.matchDeadlineMs = this.referenceTimestampMs + this.sessionStartWindowMs; + this.explicitResolvedFilePath = explicitResolvedFilePath ? normalizePath(explicitResolvedFilePath) : null; + this.explicitResumeMode = this.explicitResolvedFilePath !== null; this.sessionDatePrefixes = this.targetCwd - ? getSessionDatePrefixes(this.referenceTimestampMs, this.sessionStartWindowMs) + ? (this.explicitResumeMode ? null : getSessionDatePrefixes(this.referenceTimestampMs, this.sessionStartWindowMs)) : null; logger.debug(`[CODEX_SESSION_SCANNER] Init: targetCwd=${this.targetCwd ?? 'none'} startupTs=${new Date(this.referenceTimestampMs).toISOString()} windowMs=${this.sessionStartWindowMs}`); } public onNewSession(sessionId: string): void { + if (this.explicitResumeMode) { + return; + } if (this.activeSessionId === sessionId) { return; } @@ -118,12 +160,19 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected shouldWatchFile(filePath: string): boolean { + if (this.explicitResolvedFilePath) { + const normalizedFilePath = normalizePath(filePath); + return normalizedFilePath === this.explicitResolvedFilePath || this.linkedChildFilePaths.has(normalizedFilePath); + } if (!this.activeSessionId) { if (!this.targetCwd) { return false; } return this.getCandidateForFile(filePath) !== null; } + if (this.linkedChildFilePaths.has(normalizePath(filePath))) { + return true; + } const fileSessionId = this.sessionIdByFile.get(filePath); if (fileSessionId) { return fileSessionId === this.activeSessionId; @@ -132,7 +181,15 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected async initialize(): Promise { - const files = await this.listSessionFiles(this.sessionsRoot); + const files = await this.getSessionFilesForScan(); + if (this.explicitResolvedFilePath) { + for (const filePath of files) { + if (this.shouldWatchFile(filePath)) { + this.ensureWatcher(filePath); + } + } + return; + } for (const filePath of files) { const { nextCursor } = await this.readSessionFile(filePath, 0); this.setCursor(filePath, nextCursor); @@ -148,7 +205,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } protected async findSessionFiles(): Promise { - const files = await this.listSessionFiles(this.sessionsRoot); + const files = await this.getSessionFilesForScan(); return sortFilesByMtime(files); } @@ -169,8 +226,20 @@ class CodexSessionScannerImpl extends BaseSessionScanner { const filePath = stats.filePath; const fileSessionId = this.sessionIdByFile.get(filePath) ?? null; + if (this.explicitResolvedFilePath) { + const emittedForFile = this.emitEvents(filePath, stats.entries, fileSessionId); + if (normalizePath(filePath) === this.explicitResolvedFilePath) { + await this.linkChildTranscriptsFromParentEntries(stats.entries); + await this.linkPendingChildTranscripts(); + } + if (emittedForFile > 0) { + logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); + } + return; + } + if (!this.activeSessionId && this.targetCwd) { - this.appendPendingEvents(filePath, stats.events, fileSessionId); + this.appendPendingEvents(filePath, stats.entries, fileSessionId); const candidate = this.getCandidateForFile(filePath); if (candidate) { if (!this.bestWithinWindow || candidate.score < this.bestWithinWindow.score) { @@ -187,13 +256,21 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return; } - const emittedForFile = this.emitEvents(stats.events, fileSessionId); + const emittedForFile = this.emitEvents(filePath, stats.entries, fileSessionId); if (emittedForFile > 0) { logger.debug(`[CODEX_SESSION_SCANNER] Emitted ${emittedForFile} new events from ${filePath}`); } + const normalizedFilePath = normalizePath(filePath); + if (!this.linkedChildFilePaths.has(normalizedFilePath)) { + await this.linkChildTranscriptsFromParentEntries(stats.entries); + await this.linkPendingChildTranscripts(); + } } protected async afterScan(): Promise { + if (this.explicitResolvedFilePath) { + return; + } if (!this.activeSessionId && this.targetCwd) { if (this.bestWithinWindow) { logger.debug(`[CODEX_SESSION_SCANNER] Selected session ${this.bestWithinWindow.sessionId} within start window`); @@ -243,9 +320,17 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } private shouldSkipFile(filePath: string): boolean { + if (this.explicitResolvedFilePath) { + const normalizedFilePath = normalizePath(filePath); + return normalizedFilePath !== this.explicitResolvedFilePath && !this.linkedChildFilePaths.has(normalizedFilePath); + } if (!this.activeSessionId) { return false; } + const normalizedFilePath = normalizePath(filePath); + if (this.linkedChildFilePaths.has(normalizedFilePath)) { + return false; + } const fileSessionId = this.sessionIdByFile.get(filePath); if (fileSessionId && fileSessionId !== this.activeSessionId) { return true; @@ -302,6 +387,13 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } } + private async getSessionFilesForScan(): Promise { + if (this.explicitResolvedFilePath) { + return [this.explicitResolvedFilePath, ...this.linkedChildFilePaths]; + } + return this.listSessionFiles(this.sessionsRoot); + } + private async readSessionFile(filePath: string, startLine: number): Promise> { let content: string; try { @@ -321,8 +413,24 @@ class CodexSessionScannerImpl extends BaseSessionScanner { this.fileEpochByPath.set(filePath, nextEpoch); } + if (effectiveStartLine === 0) { + this.sessionIdByFile.delete(filePath); + this.sessionCwdByFile.delete(filePath); + this.sessionTimestampByFile.delete(filePath); + this.currentSegmentOwnerByFile.delete(filePath); + this.inSessionMetaBlockByFile.delete(filePath); + this.eventOwnerSessionIdByFile.set(filePath, new Map()); + } + const hasSessionMeta = this.sessionMetaParsed.has(filePath); const parseFrom = hasSessionMeta ? effectiveStartLine : 0; + let currentSegmentOwner = this.currentSegmentOwnerByFile.get(filePath) ?? null; + let inSessionMetaBlock = this.inSessionMetaBlockByFile.get(filePath) ?? false; + let eventOwnerByLine = this.eventOwnerSessionIdByFile.get(filePath); + if (!eventOwnerByLine) { + eventOwnerByLine = new Map(); + this.eventOwnerSessionIdByFile.set(filePath, eventOwnerByLine); + } for (let index = parseFrom; index < lines.length; index += 1) { const trimmed = lines[index].trim(); @@ -334,21 +442,29 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (parsed?.type === 'session_meta') { const payload = asRecord(parsed.payload); const sessionId = payload ? asString(payload.id) : null; - if (sessionId) { + if (sessionId && !this.sessionIdByFile.has(filePath)) { this.sessionIdByFile.set(filePath, sessionId); } const sessionCwd = payload ? asString(payload.cwd) : null; const normalizedCwd = sessionCwd ? normalizePath(sessionCwd) : null; - if (normalizedCwd) { + if (normalizedCwd && !this.sessionCwdByFile.has(filePath)) { this.sessionCwdByFile.set(filePath, normalizedCwd); } const rawTimestamp = payload ? payload.timestamp : null; const sessionTimestamp = payload ? parseTimestamp(payload.timestamp) : null; - if (sessionTimestamp !== null) { + if (sessionTimestamp !== null && !this.sessionTimestampByFile.has(filePath)) { this.sessionTimestampByFile.set(filePath, sessionTimestamp); } + if (!inSessionMetaBlock && sessionId) { + currentSegmentOwner = sessionId; + } + inSessionMetaBlock = true; + eventOwnerByLine.set(index, sessionId); logger.debug(`[CODEX_SESSION_SCANNER] Session meta: file=${filePath} cwd=${sessionCwd ?? 'none'} normalizedCwd=${normalizedCwd ?? 'none'} timestamp=${rawTimestamp ?? 'none'} parsedTs=${sessionTimestamp ?? 'none'}`); this.sessionMetaParsed.add(filePath); + } else { + inSessionMetaBlock = false; + eventOwnerByLine.set(index, currentSegmentOwner); } if (index >= effectiveStartLine) { events.push({ event: parsed, lineIndex: index }); @@ -358,6 +474,9 @@ class CodexSessionScannerImpl extends BaseSessionScanner { } } + this.currentSegmentOwnerByFile.set(filePath, currentSegmentOwner); + this.inSessionMetaBlockByFile.set(filePath, inSessionMetaBlock); + return { events, nextCursor: totalLines }; } @@ -427,41 +546,198 @@ class CodexSessionScannerImpl extends BaseSessionScanner { return this.getWatchedFiles().filter((filePath) => filePath.endsWith(suffix)); } - private appendPendingEvents(filePath: string, events: CodexSessionEvent[], fileSessionId: string | null): void { - if (events.length === 0) { + private appendPendingEvents( + filePath: string, + entries: SessionFileScanEntry[], + fileSessionId: string | null + ): void { + if (entries.length === 0) { return; } const existing = this.pendingEventsByFile.get(filePath); if (existing) { - existing.events.push(...events); + existing.entries.push(...entries); if (!existing.fileSessionId && fileSessionId) { existing.fileSessionId = fileSessionId; } return; } this.pendingEventsByFile.set(filePath, { - events: [...events], + entries: [...entries], fileSessionId }); } - private emitEvents(events: CodexSessionEvent[], fileSessionId: string | null): number { + private emitEvents( + filePath: string, + entries: SessionFileScanEntry[], + fileSessionId: string | null + ): number { let emittedForFile = 0; - for (const event of events) { + const eventOwnerByLine = this.eventOwnerSessionIdByFile.get(filePath); + const normalizedFilePath = normalizePath(filePath); + const linkedParentToolCallId = this.linkedChildParentCallIdByFile.get(normalizedFilePath) ?? null; + const childStartLine = linkedParentToolCallId + ? this.updateChildTranscriptBoundary(normalizedFilePath, entries) + : null; + if (linkedParentToolCallId && childStartLine === null) { + return 0; + } + for (const entry of entries) { + if (childStartLine !== null && entry.lineIndex !== undefined && entry.lineIndex < childStartLine) { + continue; + } + const event = entry.event; const payload = asRecord(event.payload); const payloadSessionId = payload ? asString(payload.id) : null; - const eventSessionId = payloadSessionId ?? fileSessionId ?? null; + const lineOwner = entry.lineIndex !== undefined + ? (eventOwnerByLine?.get(entry.lineIndex) ?? null) + : null; + const eventSessionId = payloadSessionId ?? lineOwner ?? fileSessionId ?? null; - if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId) { + if (this.activeSessionId && eventSessionId && eventSessionId !== this.activeSessionId && !linkedParentToolCallId) { continue; } - this.onEvent(event); + const emittedEvent = linkedParentToolCallId + ? { + ...event, + hapiSidechain: { + parentToolCallId: linkedParentToolCallId + } + } + : event; + this.onEvent(emittedEvent); emittedForFile += 1; } return emittedForFile; } + private async linkChildTranscriptsFromParentEntries(entries: SessionFileScanEntry[]): Promise { + for (const entry of entries) { + const event = entry.event; + if (event.type !== 'response_item') { + continue; + } + + const payload = asRecord(event.payload); + if (!payload) { + continue; + } + + const itemType = asString(payload.type); + const callId = extractCallId(payload); + if (!callId) { + continue; + } + + if (itemType === 'function_call') { + const toolName = asString(payload.name); + if (toolName) { + this.toolNameByCallId.set(callId, toolName); + } + continue; + } + + if (itemType !== 'function_call_output' || this.toolNameByCallId.get(callId) !== 'spawn_agent') { + continue; + } + + const childSessionId = extractAgentIdFromOutput(payload.output); + if (!childSessionId) { + continue; + } + + this.pendingChildSessionIdToParentCallId.set(childSessionId, callId); + } + } + + private async linkPendingChildTranscripts(): Promise { + if (this.pendingChildSessionIdToParentCallId.size === 0) { + return; + } + + for (const [childSessionId, parentToolCallId] of [...this.pendingChildSessionIdToParentCallId.entries()]) { + const linked = await this.linkChildTranscript(childSessionId, parentToolCallId); + if (linked) { + this.pendingChildSessionIdToParentCallId.delete(childSessionId); + } + } + } + + private async linkChildTranscript(childSessionId: string, parentToolCallId: string): Promise { + const childFilePath = await this.resolveChildTranscriptFilePath(childSessionId); + if (!childFilePath) { + return false; + } + + const normalizedChildFilePath = normalizePath(childFilePath); + if (this.linkedChildFilePaths.has(normalizedChildFilePath)) { + return true; + } + + this.linkedChildFilePaths.add(normalizedChildFilePath); + this.linkedChildParentCallIdByFile.set(normalizedChildFilePath, parentToolCallId); + this.ensureWatcher(childFilePath); + + const { events, nextCursor } = await this.readSessionFile(childFilePath, 0); + const startLine = this.updateChildTranscriptBoundary(normalizedChildFilePath, events); + if (startLine === null) { + this.setCursor(childFilePath, nextCursor); + return true; + } + + this.childTranscriptStartLineByFile.set(normalizedChildFilePath, startLine); + const childEntries = events.filter((entry) => entry.lineIndex !== undefined && entry.lineIndex >= startLine); + const processedKeys = childEntries.map((entry) => this.generateEventKey(entry.event, { + filePath: childFilePath, + lineIndex: entry.lineIndex + })); + + this.emitEvents(childFilePath, childEntries, childSessionId); + this.setCursor(childFilePath, nextCursor); + this.seedProcessedKeys(processedKeys); + return true; + } + + private updateChildTranscriptBoundary( + normalizedFilePath: string, + entries: SessionFileScanEntry[] + ): number | null { + const existingStartLine = this.childTranscriptStartLineByFile.get(normalizedFilePath); + if (existingStartLine !== undefined) { + return existingStartLine; + } + + for (const entry of entries) { + const payload = asRecord(entry.event.payload); + if (!payload || entry.lineIndex === undefined) { + continue; + } + + if (entry.event.type === 'response_item' && asString(payload.type) === 'function_call_output') { + if (stringifyOutput(payload.output).startsWith('You are the newly spawned agent.')) { + const startLine = entry.lineIndex + 1; + this.childTranscriptStartLineByFile.set(normalizedFilePath, startLine); + return startLine; + } + } + } + + return null; + } + + private async resolveChildTranscriptFilePath(childSessionId: string): Promise { + const files = await this.listSessionFiles(this.sessionsRoot); + const suffix = `-${childSessionId}.jsonl`; + const matches = files.filter((filePath) => filePath.endsWith(suffix)); + if (matches.length === 0) { + return null; + } + matches.sort((left, right) => left.localeCompare(right)); + return matches[0] ?? null; + } + private flushPendingEventsForSession(sessionId: string): void { if (this.pendingEventsByFile.size === 0) { return; @@ -473,7 +749,7 @@ class CodexSessionScannerImpl extends BaseSessionScanner { if (!matches) { continue; } - emitted += this.emitEvents(pending.events, pending.fileSessionId); + emitted += this.emitEvents(filePath, pending.entries, pending.fileSessionId); } this.pendingEventsByFile.clear(); if (emitted > 0) { @@ -519,6 +795,54 @@ function parseTimestamp(value: unknown): number | null { return null; } +function extractCallId(payload: Record): string | null { + const candidates = ['call_id', 'callId', 'tool_call_id', 'toolCallId', 'id']; + for (const key of candidates) { + const value = payload[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return null; +} + +function extractAgentIdFromOutput(output: unknown): string | null { + if (output && typeof output === 'object') { + return asString((output as Record).agent_id); + } + + if (typeof output === 'string') { + const trimmed = output.trim(); + if (!trimmed) { + return null; + } + try { + const parsed = JSON.parse(trimmed) as unknown; + if (parsed && typeof parsed === 'object') { + return asString((parsed as Record).agent_id); + } + } catch { + return null; + } + } + + return null; +} + +function stringifyOutput(output: unknown): string { + if (typeof output === 'string') { + return output; + } + if (output === null || output === undefined) { + return ''; + } + try { + return JSON.stringify(output); + } catch { + return String(output); + } +} + function normalizePath(value: string): string { const resolved = resolve(value); return process.platform === 'win32' ? resolved.toLowerCase() : resolved; diff --git a/cli/src/codex/utils/listImportableCodexSessions.test.ts b/cli/src/codex/utils/listImportableCodexSessions.test.ts new file mode 100644 index 0000000000..ebd058fd85 --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.test.ts @@ -0,0 +1,358 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { listImportableCodexSessions } from './listImportableCodexSessions'; + +describe('listImportableCodexSessions', () => { + let testDir: string; + let sessionsRoot: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `codex-importable-sessions-${Date.now()}`); + sessionsRoot = join(testDir, 'sessions'); + await mkdir(sessionsRoot, { recursive: true }); + }); + + afterEach(async () => { + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('filters child lineage blocks based on the current main segment and sorts recent-first', async () => { + const olderDir = join(sessionsRoot, '2026', '04', '03'); + const newerDir = join(sessionsRoot, '2026', '04', '04'); + await mkdir(olderDir, { recursive: true }); + await mkdir(newerDir, { recursive: true }); + + const currentMainSessionId = 'main-current-session'; + const currentMainFile = join(olderDir, `codex-${currentMainSessionId}.jsonl`); + await writeFile( + currentMainFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: 'child-lineage-session', + cwd: '/work/alpha', + timestamp: '2026-04-03T09:00:00.000Z', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'parent-thread-1' + } + } + } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'ignored child prompt' + } + }), + JSON.stringify({ + type: 'session_meta', + payload: { + id: currentMainSessionId, + cwd: '/work/alpha', + timestamp: '2026-04-03T10:00:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + content: [ + { type: 'text', text: ' build the alpha tools ' }, + { type: 'text', text: 'now' } + ] + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'session_title_change', + title: 'Alpha draft title' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'mcp__hapi__change_title', + call_id: 'title-call-1', + arguments: JSON.stringify({ + title: [ + { type: 'text', text: 'Alpha final' }, + { type: 'text', text: 'title' } + ] + }) + } + }) + ].join('\n') + '\n' + ); + + const malformedLeadingLineSessionId = 'malformed-leading-line-session'; + const malformedLeadingLineFile = join(olderDir, `codex-${malformedLeadingLineSessionId}.jsonl`); + await writeFile( + malformedLeadingLineFile, + [ + '{not valid json', + JSON.stringify({ + type: 'session_meta', + payload: { + id: malformedLeadingLineSessionId, + cwd: '/work/malformed', + timestamp: '2026-04-03T09:30:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: [ + { type: 'text', text: 'recoverable' }, + { type: 'text', text: 'transcript' } + ] + } + }) + ].join('\n') + '\n' + ); + + const childSessionId = 'child-session'; + const childFile = join(olderDir, `codex-${childSessionId}.jsonl`); + await writeFile( + childFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: childSessionId, + cwd: '/work/alpha', + timestamp: '2026-04-03T11:00:00.000Z', + source: { + subagent: { + thread_spawn: { + parent_thread_id: 'parent-thread-1' + } + } + } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'delegate this' + } + }) + ].join('\n') + '\n' + ); + + const newerSessionId = 'main-new-session'; + const newerFile = join(newerDir, `codex-${newerSessionId}.jsonl`); + await writeFile( + newerFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: newerSessionId, + cwd: '/work/beta/project', + timestamp: '2026-04-04T08:15:00.000Z' + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'What should we build?' + } + }) + ].join('\n') + '\n' + ); + + const fallbackSessionId = 'fallback-session'; + const fallbackFile = join(newerDir, `codex-${fallbackSessionId}.jsonl`); + await writeFile( + fallbackFile, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: fallbackSessionId, + cwd: '/work/gamma', + timestamp: '2026-04-02T09:30:00.000Z' + } + }) + ].join('\n') + '\n' + ); + + const result = await listImportableCodexSessions({ rootDir: sessionsRoot }); + + expect(result.sessions.map((session) => session.externalSessionId)).toEqual([ + newerSessionId, + currentMainSessionId, + malformedLeadingLineSessionId, + fallbackSessionId + ]); + + expect(result.sessions[0]).toMatchObject({ + agent: 'codex', + externalSessionId: newerSessionId, + cwd: '/work/beta/project', + timestamp: Date.parse('2026-04-04T08:15:00.000Z'), + transcriptPath: newerFile, + previewTitle: 'What should we build?', + previewPrompt: 'What should we build?' + }); + + expect(result.sessions[1]).toMatchObject({ + agent: 'codex', + externalSessionId: currentMainSessionId, + cwd: '/work/alpha', + timestamp: Date.parse('2026-04-03T10:00:00.000Z'), + transcriptPath: currentMainFile, + previewTitle: 'Alpha final title', + previewPrompt: 'build the alpha tools now' + }); + + expect(result.sessions[2]).toMatchObject({ + agent: 'codex', + externalSessionId: malformedLeadingLineSessionId, + cwd: '/work/malformed', + timestamp: Date.parse('2026-04-03T09:30:00.000Z'), + transcriptPath: malformedLeadingLineFile, + previewTitle: 'recoverable transcript', + previewPrompt: 'recoverable transcript' + }); + + expect(result.sessions[3]).toMatchObject({ + agent: 'codex', + externalSessionId: fallbackSessionId, + cwd: '/work/gamma', + timestamp: Date.parse('2026-04-02T09:30:00.000Z'), + transcriptPath: fallbackFile, + previewTitle: 'gamma', + previewPrompt: null + }); + + expect(result.sessions.find((session) => session.externalSessionId === childSessionId)).toBeUndefined(); + }); + + it('extracts the latest root turn context configuration', async () => { + const sessionDir = join(sessionsRoot, '2026', '04', '22'); + await mkdir(sessionDir, { recursive: true }); + + const sessionId = 'codex-config-session'; + const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`); + await writeFile( + transcriptPath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: sessionId, + cwd: '/work/project', + timestamp: '2026-04-22T08:00:00.000Z' + } + }), + JSON.stringify({ + type: 'turn_context', + payload: { + model: 'gpt-5.4', + effort: 'xhigh', + approval_policy: 'on-request', + sandbox_policy: { type: 'workspaceWrite' }, + collaboration_mode: { + mode: 'default', + settings: { + model: 'gpt-5.4', + reasoning_effort: 'xhigh' + } + } + } + }), + JSON.stringify({ + type: 'turn_context', + payload: { + model: 'gpt-5.4-mini', + service_tier: 'fast', + effort: null, + approval_policy: 'never', + sandbox_policy: { type: 'danger-full-access' }, + collaboration_mode: { + mode: 'plan', + settings: { + model: 'gpt-5.4-mini', + reasoning_effort: 'high' + } + } + } + }) + ].join('\n') + '\n' + ); + + const result = await listImportableCodexSessions({ rootDir: sessionsRoot }); + + expect(result.sessions[0]).toMatchObject({ + externalSessionId: sessionId, + model: 'gpt-5.4-mini', + effort: null, + modelReasoningEffort: 'high', + serviceTier: 'fast', + collaborationMode: 'plan', + approvalPolicy: 'never', + sandboxPolicy: { type: 'danger-full-access' }, + permissionMode: 'yolo' + }); + }); + + it('uses Codex config service tier when transcript does not include it', async () => { + await writeFile(join(testDir, 'config.toml'), 'service_tier = "fast"\n'); + const sessionDir = join(sessionsRoot, '2026', '04', '22'); + await mkdir(sessionDir, { recursive: true }); + + const sessionId = 'codex-service-tier-session'; + const transcriptPath = join(sessionDir, `rollout-${sessionId}.jsonl`); + await writeFile( + transcriptPath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { + id: sessionId, + cwd: '/work/project', + timestamp: '2026-04-22T08:00:00.000Z' + } + }), + JSON.stringify({ + type: 'turn_context', + payload: { + model: 'gpt-5.4', + effort: 'xhigh', + approval_policy: 'never', + sandbox_policy: { type: 'danger-full-access' }, + collaboration_mode: { + mode: 'default', + settings: { + reasoning_effort: 'xhigh' + } + } + } + }) + ].join('\n') + '\n' + ); + + const result = await listImportableCodexSessions({ rootDir: sessionsRoot }); + + expect(result.sessions[0]).toMatchObject({ + externalSessionId: sessionId, + serviceTier: 'fast' + }); + }); +}); diff --git a/cli/src/codex/utils/listImportableCodexSessions.ts b/cli/src/codex/utils/listImportableCodexSessions.ts new file mode 100644 index 0000000000..2bf5d12348 --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.ts @@ -0,0 +1,526 @@ +import { homedir } from 'node:os'; +import { basename, dirname, join } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; +import type { ImportableCodexSessionSummary } from '@hapi/protocol/rpcTypes'; +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types'; + +export type ListImportableCodexSessionsOptions = { + rootDir?: string; + configPath?: string; +}; + +export async function listImportableCodexSessions( + opts: ListImportableCodexSessionsOptions = {} +): Promise<{ sessions: ImportableCodexSessionSummary[] }> { + const sessionsRoot = opts.rootDir?.trim() ? opts.rootDir : getCodexSessionsRoot(); + const fallbackServiceTier = await readCodexServiceTier(opts.configPath ?? getCodexConfigPathForSessionsRoot(sessionsRoot)); + const transcriptPaths = (await collectJsonlFiles(sessionsRoot)).sort((a, b) => a.localeCompare(b)); + const summaries = (await Promise.all(transcriptPaths.map(async (transcriptPath) => scanCodexTranscript(transcriptPath, fallbackServiceTier)))) + .filter((summary): summary is ImportableCodexSessionSummary => summary !== null); + + summaries.sort(compareImportableCodexSessions); + + return { sessions: summaries }; +} + +async function scanCodexTranscript( + transcriptPath: string, + fallbackServiceTier: string | null +): Promise { + let content: string; + try { + content = await readFile(transcriptPath, 'utf-8'); + } catch { + return null; + } + + const lines = content.split(/\r?\n/); + const records = lines + .map((line, lineIndex) => ({ + lineIndex, + record: parseJsonLine(line) + })) + .filter((entry): entry is { lineIndex: number; record: Record } => entry.record !== null); + + const sessionMetaEntries = records.filter((entry) => isSessionMetaRecord(entry.record)); + if (sessionMetaEntries.length === 0) { + return null; + } + + const sessionMetaEntry = [...sessionMetaEntries].reverse().find((entry) => { + const payload = getRecord(entry.record.payload); + return getString(payload?.id) !== null; + }); + if (!sessionMetaEntry) { + return null; + } + + const payload = getRecord(sessionMetaEntry.record.payload); + const externalSessionId = getString(payload?.id); + if (!externalSessionId) { + return null; + } + + if (isChildCodexSession(payload)) { + return null; + } + + const cwd = getString(payload?.cwd); + const timestamp = parseTimestamp(payload?.timestamp); + + let latestRootTitleChange: string | null = null; + let firstRootPrompt: string | null = null; + let latestRootConfig: CodexTranscriptConfig = {}; + + for (const entry of records) { + if (entry.lineIndex <= sessionMetaEntry.lineIndex) { + continue; + } + + const turnConfig = extractRootTurnContextConfig(entry.record); + if (turnConfig) { + latestRootConfig = turnConfig; + continue; + } + + if (isRootTitleChangeRecord(entry.record)) { + const title = extractTitleFromRecord(entry.record); + if (title) { + latestRootTitleChange = title; + } + continue; + } + + const prompt = extractRootPromptFromRecord(entry.record); + if (prompt && !firstRootPrompt) { + firstRootPrompt = prompt; + } + } + + const previewPrompt = firstRootPrompt; + const previewTitle = latestRootTitleChange + ?? firstRootPrompt + ?? deriveCwdPreview(cwd) + ?? shortExternalSessionId(externalSessionId); + + const summary: ImportableCodexSessionSummary = { + agent: 'codex', + externalSessionId, + cwd, + timestamp, + transcriptPath, + previewTitle, + previewPrompt, + ...latestRootConfig + }; + + if (!Object.hasOwn(summary, 'serviceTier') && fallbackServiceTier !== null) { + summary.serviceTier = fallbackServiceTier; + } + + return summary; +} + +function getCodexSessionsRoot(): string { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + return join(codexHome, 'sessions'); +} + +function getCodexConfigPathForSessionsRoot(sessionsRoot: string): string { + return join(dirname(sessionsRoot), 'config.toml'); +} + +async function readCodexServiceTier(configPath: string): Promise { + let content: string; + try { + content = await readFile(configPath, 'utf-8'); + } catch { + return null; + } + + const match = content.match(/^\s*service_tier\s*=\s*(?:"([^"]+)"|'([^']+)'|([^\s#]+))/m); + const value = match?.[1] ?? match?.[2] ?? match?.[3] ?? null; + return value?.trim() || null; +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; + } catch { + return []; + } +} + +function parseJsonLine(line: string): Record | null { + try { + const parsed = JSON.parse(line) as unknown; + return getRecord(parsed); + } catch { + return null; + } +} + +function isSessionMetaRecord(value: Record | null): value is Record { + return getString(value?.type) === 'session_meta' && getRecord(value?.payload) !== null; +} + +function isChildCodexSession(payload: Record | null): boolean { + return hasNestedValue(payload, ['source', 'subagent', 'thread_spawn', 'parent_thread_id']); +} + +function isRootTitleChangeRecord(record: Record): boolean { + if (isSidechainRecord(record)) { + return false; + } + + if (getString(record.type) === 'session_title_change') { + return true; + } + + const payload = getRecord(record.payload); + if (!payload) { + return false; + } + + const payloadType = getString(payload.type); + if (payloadType === 'session_title_change') { + return true; + } + + if (payloadType !== 'function_call' && payloadType !== 'mcpToolCall') { + return false; + } + + const toolName = getString(payload.name ?? payload.tool); + return typeof toolName === 'string' && toolName.endsWith('change_title'); +} + +function extractTitleFromRecord(record: Record): string | null { + const payload = getRecord(record.payload); + if (!payload) { + return extractTextValue(record.title); + } + + const payloadType = getString(payload.type); + if (payloadType === 'session_title_change') { + return extractTextValue(payload.title); + } + + if (payloadType === 'function_call' || payloadType === 'mcpToolCall') { + const argumentsValue = payload.arguments ?? payload.arguments_json ?? payload.input; + const argumentsValueRecord = parseMaybeJson(argumentsValue); + const title = extractTextValue(argumentsValueRecord?.title ?? argumentsValueRecord); + if (title) { + return title; + } + } + + return extractTextValue(payload.title) ?? extractTextValue(record.title); +} + +function extractRootPromptFromRecord(record: Record): string | null { + if (isSidechainRecord(record)) { + return null; + } + + const type = getString(record.type); + const payload = getRecord(record.payload); + const promptSources = [ + payload?.message, + payload?.text, + payload?.content, + payload?.input, + payload?.body, + record.message, + record.text, + record.content, + record.input, + record.body + ]; + + if (type === 'event_msg' || type === 'event') { + const eventType = getString(payload?.type); + if (eventType === 'user_message' || eventType === 'userMessage') { + return extractTextValue(promptSources); + } + } + + if (type === 'user_message' || type === 'userMessage') { + return extractTextValue(promptSources); + } + + if (type === 'response_item' || type === 'item') { + const itemType = getString(payload?.type); + if (itemType === 'user_message' || itemType === 'userMessage') { + return extractTextValue(promptSources); + } + } + + return null; +} + +type CodexTranscriptConfig = { + model?: string | null; + effort?: string | null; + modelReasoningEffort?: string | null; + serviceTier?: string | null; + collaborationMode?: CodexCollaborationMode | null; + approvalPolicy?: string | null; + sandboxPolicy?: unknown | null; + permissionMode?: PermissionMode | null; +}; + +function extractRootTurnContextConfig(record: Record): CodexTranscriptConfig | null { + if (isSidechainRecord(record)) { + return null; + } + + const payload = getRecord(record.payload); + const isTurnContext = getString(record.type) === 'turn_context' + || getString(payload?.type) === 'turn_context'; + if (!isTurnContext) { + return null; + } + + const context = payload ?? record; + const collaborationMode = getRecord(context.collaboration_mode); + const collaborationSettings = getRecord(collaborationMode?.settings); + const model = getNullableString(context.model) + ?? getNullableString(collaborationSettings?.model); + const effort = getNullableString(context.effort); + const reasoningEffortValue = collaborationSettings && Object.hasOwn(collaborationSettings, 'reasoning_effort') + ? getNullableString(collaborationSettings.reasoning_effort) + : effort; + const parsedCollaborationMode = parseCodexCollaborationMode(collaborationMode?.mode); + const approvalPolicy = getNullableString(context.approval_policy); + const sandboxPolicy = getRecord(context.sandbox_policy); + const hasServiceTier = Object.hasOwn(context, 'service_tier'); + + const config: CodexTranscriptConfig = { + model, + effort, + modelReasoningEffort: reasoningEffortValue, + collaborationMode: parsedCollaborationMode, + approvalPolicy, + sandboxPolicy, + permissionMode: inferPermissionMode(approvalPolicy, sandboxPolicy) + }; + + if (hasServiceTier) { + config.serviceTier = getNullableString(context.service_tier); + } + + return config; +} + +function isSidechainRecord(record: Record): boolean { + if (record.hapiSidechain && typeof record.hapiSidechain === 'object') { + return true; + } + + const payload = getRecord(record.payload); + if (!payload) { + return false; + } + + if (payload.parent_tool_call_id || payload.parentToolCallId || payload.isSidechain) { + return true; + } + + return hasNestedValue(payload, ['hapiSidechain']); +} + +function parseMaybeJson(value: unknown): Record | null { + if (value === null || value === undefined) { + return null; + } + + if (typeof value === 'object') { + return getRecord(value); + } + + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + try { + return getRecord(JSON.parse(trimmed)); + } catch { + return null; + } + } + + return null; +} + +function extractTextValue(value: unknown): string | null { + const chunks = extractTextChunks(value); + if (chunks.length === 0) { + return null; + } + + return normalizePreviewText(chunks.join(' ')); +} + +function extractTextChunks(value: unknown): string[] { + if (typeof value === 'string') { + const normalized = normalizePreviewText(value); + return normalized ? [normalized] : []; + } + + if (Array.isArray(value)) { + const chunks: string[] = []; + for (const entry of value) { + chunks.push(...extractTextChunks(entry)); + } + return chunks; + } + + const record = getRecord(value); + if (!record) { + return []; + } + + const directKeys = ['title', 'message', 'text', 'content', 'input', 'body'] as const; + + for (const key of directKeys) { + const entryValue = record[key]; + if (entryValue === undefined || entryValue === null) { + continue; + } + const chunks = extractTextChunks(entryValue); + if (chunks.length > 0) { + return chunks; + } + } + + return []; +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.trim().length > 0) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function deriveCwdPreview(cwd: string | null): string | null { + if (!cwd) { + return null; + } + + const trimmed = cwd.trim(); + if (!trimmed) { + return null; + } + + const segment = basename(trimmed); + return segment.length > 0 ? normalizePreviewText(segment) : null; +} + +function shortExternalSessionId(externalSessionId: string): string { + return externalSessionId.length > 8 ? externalSessionId.slice(0, 8) : externalSessionId; +} + +function normalizePreviewText(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +function compareImportableCodexSessions( + left: ImportableCodexSessionSummary, + right: ImportableCodexSessionSummary +): number { + const leftTimestamp = left.timestamp ?? Number.NEGATIVE_INFINITY; + const rightTimestamp = right.timestamp ?? Number.NEGATIVE_INFINITY; + + if (leftTimestamp !== rightTimestamp) { + return rightTimestamp - leftTimestamp; + } + + return left.transcriptPath.localeCompare(right.transcriptPath); +} + +function getRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object') { + return null; + } + + return value as Record; +} + +function getString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function getNullableString(value: unknown): string | null { + if (value === null) { + return null; + } + + return getString(value); +} + +function parseCodexCollaborationMode(value: unknown): CodexCollaborationMode | null { + return value === 'default' || value === 'plan' ? value : null; +} + +function inferPermissionMode( + approvalPolicy: string | null, + sandboxPolicy: Record | null +): PermissionMode | null { + const sandboxType = getString(sandboxPolicy?.type); + if (!approvalPolicy || !sandboxType) { + return null; + } + + if (approvalPolicy === 'never' && (sandboxType === 'dangerFullAccess' || sandboxType === 'danger-full-access')) { + return 'yolo'; + } + + if (approvalPolicy === 'never' && (sandboxType === 'readOnly' || sandboxType === 'read-only')) { + return 'read-only'; + } + + if (approvalPolicy === 'on-failure' && (sandboxType === 'workspaceWrite' || sandboxType === 'workspace-write')) { + return 'safe-yolo'; + } + + if (approvalPolicy === 'on-request' && (sandboxType === 'workspaceWrite' || sandboxType === 'workspace-write')) { + return 'default'; + } + + return null; +} + +function hasNestedValue(value: Record | null, path: string[]): boolean { + let current: unknown = value; + + for (const segment of path) { + if (!current || typeof current !== 'object') { + return false; + } + + current = (current as Record)[segment]; + } + + return current !== undefined && current !== null && (!(typeof current === 'string') || current.length > 0); +} diff --git a/cli/src/codex/utils/resolveCodexSessionFile.test.ts b/cli/src/codex/utils/resolveCodexSessionFile.test.ts new file mode 100644 index 0000000000..fe69d2f6ea --- /dev/null +++ b/cli/src/codex/utils/resolveCodexSessionFile.test.ts @@ -0,0 +1,215 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveCodexSessionFile } from './resolveCodexSessionFile'; + +describe('resolveCodexSessionFile', () => { + let testDir: string; + let sessionsDir: string; + let originalCodexHome: string | undefined; + + beforeEach(async () => { + testDir = join(tmpdir(), `codex-session-resolver-${Date.now()}`); + sessionsDir = join(testDir, 'sessions', '2026', '04', '02'); + await mkdir(sessionsDir, { recursive: true }); + + originalCodexHome = process.env.CODEX_HOME; + process.env.CODEX_HOME = testDir; + }); + + afterEach(async () => { + if (originalCodexHome === undefined) { + delete process.env.CODEX_HOME; + } else { + process.env.CODEX_HOME = originalCodexHome; + } + + if (existsSync(testDir)) { + await rm(testDir, { recursive: true, force: true }); + } + }); + + it('finds a unique matching transcript file', async () => { + const sessionId = 'session-unique'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + [ + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/unique', timestamp: '2026-04-02T01:02:03.000Z' } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'hello' } }) + ].join('\n') + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: '/work/unique', + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); + + it('succeeds when session_meta is missing cwd', async () => { + const sessionId = 'session-missing-cwd'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: null, + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); + + it('succeeds when session_meta is missing timestamp', async () => { + const sessionId = 'session-missing-timestamp'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/missing-timestamp' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath, + cwd: '/work/missing-timestamp', + timestamp: null + }); + }); + + it('returns not_found when no transcript matches', async () => { + const result = await resolveCodexSessionFile('session-missing'); + + expect(result).toEqual({ + status: 'not_found' + }); + }); + + it('returns ambiguous when multiple files match the same session id suffix', async () => { + const sessionId = 'session-ambiguous'; + const firstFile = join(sessionsDir, `codex-${sessionId}.jsonl`); + const secondDir = join(testDir, 'sessions', '2026', '04', '01'); + await mkdir(secondDir, { recursive: true }); + const secondFile = join(secondDir, `codex-${sessionId}.jsonl`); + + const meta = JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/ambiguous', timestamp: '2026-04-02T01:02:03.000Z' } + }); + await writeFile(firstFile, meta + '\n'); + await writeFile(secondFile, meta + '\n'); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'ambiguous', + filePaths: [secondFile, firstFile] + }); + }); + + it('returns invalid for an invalid first line', async () => { + const sessionId = 'session-invalid-first-line'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile(filePath, JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message' } }) + '\n'); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'invalid_session_meta' + }); + }); + + it('returns invalid when the first line is session_meta but fields are invalid', async () => { + const sessionId = 'session-invalid-meta'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { cwd: '/work/invalid-meta', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'invalid_session_meta' + }); + }); + + it('returns invalid when session_meta payload id mismatches the requested session id', async () => { + const sessionId = 'session-requested'; + const filePath = join(sessionsDir, `codex-${sessionId}.jsonl`); + await writeFile( + filePath, + JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-other', cwd: '/work/mismatch', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'invalid', + filePath, + reason: 'session_id_mismatch' + }); + }); + + it('resolves to the valid transcript when a corrupt duplicate suffix also exists', async () => { + const sessionId = 'session-mixed'; + const validFile = join(sessionsDir, `codex-${sessionId}.jsonl`); + const invalidDir = join(testDir, 'sessions', '2026', '04', '01'); + await mkdir(invalidDir, { recursive: true }); + const invalidFile = join(invalidDir, `codex-${sessionId}.jsonl`); + + await writeFile( + validFile, + JSON.stringify({ + type: 'session_meta', + payload: { id: sessionId, cwd: '/work/mixed', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + await writeFile( + invalidFile, + JSON.stringify({ + type: 'session_meta', + payload: { id: 'session-other', cwd: '/work/corrupt', timestamp: '2026-04-02T01:02:03.000Z' } + }) + '\n' + ); + + const result = await resolveCodexSessionFile(sessionId); + + expect(result).toEqual({ + status: 'found', + filePath: validFile, + cwd: '/work/mixed', + timestamp: Date.parse('2026-04-02T01:02:03.000Z') + }); + }); +}); diff --git a/cli/src/codex/utils/resolveCodexSessionFile.ts b/cli/src/codex/utils/resolveCodexSessionFile.ts new file mode 100644 index 0000000000..2c602d6d9c --- /dev/null +++ b/cli/src/codex/utils/resolveCodexSessionFile.ts @@ -0,0 +1,177 @@ +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; + +export type ResolveCodexSessionFileResult = + | { + status: 'found'; + filePath: string; + cwd: string | null; + timestamp: number | null; + } + | { + status: 'not_found'; + } + | { + status: 'ambiguous'; + filePaths: string[]; + } + | { + status: 'invalid'; + filePath: string; + reason: 'invalid_session_meta' | 'session_id_mismatch'; + }; + +export async function resolveCodexSessionFile(sessionId: string): Promise { + const sessionsRoot = getCodexSessionsRoot(); + const suffix = `-${sessionId}.jsonl`; + const files = (await collectJsonlFiles(sessionsRoot)) + .filter((filePath) => filePath.endsWith(suffix)) + .sort((a, b) => a.localeCompare(b)); + + if (files.length === 0) { + return { status: 'not_found' }; + } + + const candidates = await Promise.all(files.map(async (filePath) => validateSessionMeta(filePath, sessionId))); + const validCandidates = candidates.filter((candidate): candidate is ValidSessionFileCandidate => candidate.status === 'found'); + const invalidCandidates = candidates.filter((candidate): candidate is InvalidSessionFileCandidate => candidate.status === 'invalid'); + + if (validCandidates.length === 1) { + return validCandidates[0]; + } + + if (validCandidates.length > 1) { + return { + status: 'ambiguous', + filePaths: validCandidates.map((candidate) => candidate.filePath) + }; + } + + if (files.length === 1) { + return invalidCandidates[0] ?? { status: 'invalid', filePath: files[0], reason: 'invalid_session_meta' }; + } + + return { + status: 'ambiguous', + filePaths: files + }; +} + +function getCodexSessionsRoot(): string { + const codexHome = process.env.CODEX_HOME || join(homedir(), '.codex'); + return join(codexHome, 'sessions'); +} + +async function collectJsonlFiles(root: string): Promise { + try { + const entries = await readdir(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + const fullPath = join(root, entry.name); + if (entry.isDirectory()) { + files.push(...await collectJsonlFiles(fullPath)); + } else if (entry.isFile() && entry.name.endsWith('.jsonl')) { + files.push(fullPath); + } + } + + return files; + } catch { + return []; + } +} + +type ValidSessionFileCandidate = { + status: 'found'; + filePath: string; + cwd: string | null; + timestamp: number | null; +}; + +type InvalidSessionFileCandidate = { + status: 'invalid'; + filePath: string; + reason: 'invalid_session_meta' | 'session_id_mismatch'; +}; + +async function validateSessionMeta(filePath: string, sessionId: string): Promise { + let content: string; + try { + content = await readFile(filePath, 'utf-8'); + } catch { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + const firstLine = content.split(/\r?\n/, 1)[0]?.trim(); + if (!firstLine) { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(firstLine); + } catch { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + if (!isSessionMeta(parsed)) { + return { status: 'invalid', filePath, reason: 'invalid_session_meta' }; + } + + const payload = parsed.payload; + if (payload.id !== sessionId) { + return { status: 'invalid', filePath, reason: 'session_id_mismatch' }; + } + + return { + status: 'found', + filePath, + cwd: parseOptionalString(payload.cwd), + timestamp: parseOptionalTimestamp(payload.timestamp) + }; +} + +function isSessionMeta(value: unknown): value is { type: 'session_meta'; payload: { id: string; cwd?: unknown; timestamp?: unknown } } { + if (!value || typeof value !== 'object') { + return false; + } + + const record = value as Record; + if (record.type !== 'session_meta') { + return false; + } + + const payload = record.payload; + if (!payload || typeof payload !== 'object') { + return false; + } + + const payloadRecord = payload as Record; + return typeof payloadRecord.id === 'string' && payloadRecord.id.length > 0; +} + +function parseTimestamp(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return value; + } + + if (typeof value === 'string' && value.length > 0) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? null : parsed; + } + + return null; +} + +function parseOptionalString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function parseOptionalTimestamp(value: unknown): number | null { + if (value === undefined || value === null || value === '') { + return null; + } + return parseTimestamp(value); +} diff --git a/cli/src/codex/utils/spawnNicknameResolver.test.ts b/cli/src/codex/utils/spawnNicknameResolver.test.ts new file mode 100644 index 0000000000..41a5447a28 --- /dev/null +++ b/cli/src/codex/utils/spawnNicknameResolver.test.ts @@ -0,0 +1,41 @@ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { resolveCodexSubagentNickname } from './spawnNicknameResolver'; + +describe('resolveCodexSubagentNickname', () => { + let testDir: string; + + beforeEach(async () => { + testDir = join(tmpdir(), `codex-nickname-${Date.now()}`); + await mkdir(join(testDir, 'sessions', '2026', '04', '22'), { recursive: true }); + }); + + afterEach(async () => { + await rm(testDir, { recursive: true, force: true }); + }); + + it('reads nickname from child transcript session metadata', async () => { + const agentId = '019db5e6-d00a-7060-998e-bc6e4513f6cb'; + await writeFile( + join(testDir, 'sessions', '2026', '04', '22', `rollout-2026-04-22T23-54-55-${agentId}.jsonl`), + JSON.stringify({ + type: 'session_meta', + payload: { + id: agentId, + agent_nickname: 'Ptolemy', + source: { + subagent: { + thread_spawn: { + agent_nickname: 'Ptolemy' + } + } + } + } + }) + '\n' + ); + + await expect(resolveCodexSubagentNickname(agentId, { codexHomeDir: testDir })).resolves.toBe('Ptolemy'); + }); +}); diff --git a/cli/src/codex/utils/spawnNicknameResolver.ts b/cli/src/codex/utils/spawnNicknameResolver.ts new file mode 100644 index 0000000000..7cc988114d --- /dev/null +++ b/cli/src/codex/utils/spawnNicknameResolver.ts @@ -0,0 +1,93 @@ +import { readdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { homedir } from 'node:os'; + +type ResolveOptions = { + codexHomeDir?: string; +}; + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? value as Record + : null; +} + +function asNonEmptyString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +function extractNickname(payload: Record): string | null { + const direct = asNonEmptyString(payload.agent_nickname ?? payload.agentNickname ?? payload.nickname); + if (direct) return direct; + + const source = asRecord(payload.source); + const subagent = asRecord(source?.subagent); + const threadSpawn = asRecord(subagent?.thread_spawn ?? subagent?.threadSpawn); + return asNonEmptyString(threadSpawn?.agent_nickname ?? threadSpawn?.agentNickname ?? subagent?.agent_nickname); +} + +async function findSessionFile(root: string, sessionId: string): Promise { + const suffix = `-${sessionId}.jsonl`; + const stack = [root]; + + while (stack.length > 0) { + const dir = stack.pop(); + if (!dir) continue; + + let entries; + try { + entries = await readdir(dir, { withFileTypes: true }); + } catch { + continue; + } + + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isDirectory()) { + stack.push(path); + continue; + } + if (entry.isFile() && entry.name.endsWith(suffix)) { + return path; + } + } + } + + return null; +} + +export async function resolveCodexSubagentNickname( + agentId: string, + options: ResolveOptions = {} +): Promise { + if (agentId.length === 0) return null; + + const codexHomeDir = options.codexHomeDir ?? process.env.CODEX_HOME ?? join(homedir(), '.codex'); + const sessionFile = await findSessionFile(join(codexHomeDir, 'sessions'), agentId); + if (!sessionFile) return null; + + let text: string; + try { + text = await readFile(sessionFile, 'utf8'); + } catch { + return null; + } + + for (const line of text.split('\n')) { + if (!line.trim()) continue; + let event: unknown; + try { + event = JSON.parse(line); + } catch { + continue; + } + const record = asRecord(event); + if (record?.type !== 'session_meta') continue; + + const payload = asRecord(record.payload); + if (!payload) return null; + return extractNickname(payload); + } + + return null; +} diff --git a/cli/src/codex/utils/systemPrompt.test.ts b/cli/src/codex/utils/systemPrompt.test.ts new file mode 100644 index 0000000000..0888bb22ee --- /dev/null +++ b/cli/src/codex/utils/systemPrompt.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { codexSystemPrompt } from './systemPrompt'; + +describe('codexSystemPrompt', () => { + it('does not force Codex to call the HAPI title tool for every session', () => { + expect(codexSystemPrompt).not.toContain('ALWAYS'); + expect(codexSystemPrompt).toContain('Only call'); + expect(codexSystemPrompt).toContain('explicitly asks'); + }); +}); diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index c8be66202a..98a74867c5 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -1,22 +1,22 @@ /** * Codex-specific system prompt for local mode. * - * This prompt instructs Codex to call the hapi__change_title function - * to set appropriate chat session titles. + * This prompt keeps the HAPI title tool available without forcing an + * extra title-tool turn in every Codex session. */ import { trimIdent } from '@/utils/trimIdent'; /** - * Title instruction for Codex to call the hapi MCP tool. + * Title instruction for Codex's HAPI MCP tool. * Note: Codex exposes MCP tools under the `functions.` namespace, * so the tool is called as `functions.hapi__change_title`. */ export const TITLE_INSTRUCTION = trimIdent(` - ALWAYS when you start a new chat, call the title tool to set a concise task title. - Prefer calling functions.hapi__change_title. - If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. - If the task focus changes significantly later, call the title tool again with a better title. + Do not call the HAPI title tool automatically. + Only call functions.hapi__change_title if the user explicitly asks to rename the current chat. + If that exact tool name is unavailable, use an equivalent alias such as hapi__change_title, mcp__hapi__change_title, hapi_change_title, or change_title. + Keep title tool calls silent; never mention title changes in the chat response. `); /** diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index b97baa90d4..bbb8ab5a26 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -35,6 +35,7 @@ export const codexCommand: CommandDefinition = { resumeSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort + serviceTier?: string } = {} const unknownArgs: string[] = [] let hasExplicitPermissionMode = false @@ -75,6 +76,12 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --model-reasoning-effort value') } options.modelReasoningEffort = parseReasoningEffort(effort) + } else if (arg === '--service-tier') { + const serviceTier = commandArgs[++i] + if (!serviceTier) { + throw new Error('Missing --service-tier value') + } + options.serviceTier = serviceTier } else { unknownArgs.push(arg) } diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 6336f57dd8..9b77f40063 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -1,3 +1,10 @@ +export type { + ImportableCodexSessionSummary, + ImportableSessionAgent, + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse +} from '@hapi/protocol/rpcTypes' + export interface SpawnSessionOptions { machineId?: string directory: string @@ -8,6 +15,7 @@ export interface SpawnSessionOptions { model?: string effort?: string modelReasoningEffort?: string + serviceTier?: string yolo?: boolean permissionMode?: string token?: string diff --git a/cli/src/modules/common/session/BaseSessionScanner.ts b/cli/src/modules/common/session/BaseSessionScanner.ts index e19d0e751c..754896354b 100644 --- a/cli/src/modules/common/session/BaseSessionScanner.ts +++ b/cli/src/modules/common/session/BaseSessionScanner.ts @@ -13,6 +13,7 @@ export type SessionFileScanResult = { export type SessionFileScanStats = { filePath: string; + entries: SessionFileScanEntry[]; events: TEvent[]; parsedCount: number; newCount: number; @@ -156,6 +157,7 @@ export abstract class BaseSessionScanner { const cursor = this.getCursor(filePath); const { events, nextCursor } = await this.parseSessionFile(filePath, cursor); const newEvents: TEvent[] = []; + const newEntries: SessionFileScanEntry[] = []; const newKeys: string[] = []; for (const entry of events) { const key = this.generateEventKey(entry.event, { filePath, lineIndex: entry.lineIndex }); @@ -165,9 +167,11 @@ export abstract class BaseSessionScanner { } newKeys.push(key); newEvents.push(entry.event); + newEntries.push(entry); } await this.handleFileScan({ filePath, + entries: newEntries, events: newEvents, parsedCount: events.length, newCount: newEvents.length, diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index d5e7302ebe..b440020b8f 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -857,6 +857,9 @@ export function buildCliArgs( if (options.modelReasoningEffort && agent === 'codex') { args.push('--model-reasoning-effort', options.modelReasoningEffort); } + if (options.serviceTier && agent === 'codex') { + args.push('--service-tier', options.serviceTier); + } if (options.permissionMode && (PERMISSION_MODES as readonly string[]).includes(options.permissionMode)) { args.push('--permission-mode', options.permissionMode); } else if (yolo) { diff --git a/cli/src/utils/spawnHappyCLI.test.ts b/cli/src/utils/spawnHappyCLI.test.ts index 69c2b7a2ea..3afc836d46 100644 --- a/cli/src/utils/spawnHappyCLI.test.ts +++ b/cli/src/utils/spawnHappyCLI.test.ts @@ -3,13 +3,14 @@ import type { SpawnOptions } from 'child_process'; const spawnMock = vi.fn((..._args: any[]) => ({ pid: 12345 } as any)); -vi.mock('child_process', async () => { - const actual = await vi.importActual('child_process'); - return { - ...actual, - spawn: spawnMock - }; -}); +vi.mock('child_process', () => ({ + spawn: spawnMock, + spawnSync: vi.fn(), + exec: vi.fn(), + execSync: vi.fn(), + execFile: vi.fn(), + execFileSync: vi.fn(), +})); const originalPlatformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform'); const originalInvokedCwd = process.env.HAPI_INVOKED_CWD; @@ -104,10 +105,10 @@ describe('spawnHappyCLI windowsHide behavior', () => { expect(command.command).toBe(process.execPath); if (isBunRuntime) { expect(command.args[0]).toBe('--cwd'); - expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/hapi\/cli$/); - expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/hapi\/cli\/src\/index\.ts$/); + expect(command.args[1].replace(/\\/g, '/')).toMatch(/\/cli$/); + expect(command.args[2].replace(/\\/g, '/')).toMatch(/\/cli\/src\/index\.ts$/); } else { - expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/hapi/cli/src/index.ts'))).toBe(true); + expect(command.args.some((arg) => arg.replace(/\\/g, '/').endsWith('/cli/src/index.ts'))).toBe(true); } }); diff --git a/hub/package.json b/hub/package.json index aa7c78fa1f..c1d8b86a03 100644 --- a/hub/package.json +++ b/hub/package.json @@ -11,7 +11,7 @@ "dev": "bun --watch run src/index.ts", "test": "bun test", "typecheck": "tsc --noEmit", - "build": "bun build src/index.ts --outdir dist --target bun", + "build": "bun run generate:embedded-web-assets && bun build src/index.ts --outdir dist --target bun", "generate:embedded-web-assets": "bun run scripts/generate-embedded-web-assets.ts" }, "dependencies": { diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index 0cfaa7c4ff..5821c2640a 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -60,6 +60,7 @@ function createSession(overrides: Partial = {}): Session { model: null, modelReasoningEffort: null, effort: null, + serviceTier: null, ...overrides } } diff --git a/hub/src/socket/handlers/cli/index.ts b/hub/src/socket/handlers/cli/index.ts index 2d0ca220d0..34b97cb5a5 100644 --- a/hub/src/socket/handlers/cli/index.ts +++ b/hub/src/socket/handlers/cli/index.ts @@ -19,6 +19,7 @@ type SessionAlivePayload = { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index ab14052dac..6437888cd2 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -19,6 +19,7 @@ type SessionAlivePayload = { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f9ac76ba35..3715223af9 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -22,7 +22,7 @@ export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' -const SCHEMA_VERSION: number = 7 +const SCHEMA_VERSION: number = 8 const REQUIRED_TABLES = [ 'sessions', 'machines', @@ -83,6 +83,18 @@ export class Store { this.push = new PushStore(this.db) } + runInTransaction(fn: () => T): T { + try { + this.db.exec('BEGIN') + const result = fn() + this.db.exec('COMMIT') + return result + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + private initSchema(): void { const currentVersion = this.getUserVersion() if (currentVersion === 0) { @@ -156,6 +168,36 @@ export class Store { return } + if (currentVersion === 7 && SCHEMA_VERSION === 8) { + this.migrateFromV7ToV8() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 6 && SCHEMA_VERSION === 8) { + this.migrateFromV6ToV7() + this.migrateFromV7ToV8() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 5 && SCHEMA_VERSION === 8) { + this.migrateFromV5ToV6() + this.migrateFromV6ToV7() + this.migrateFromV7ToV8() + this.setUserVersion(SCHEMA_VERSION) + return + } + + if (currentVersion === 4 && SCHEMA_VERSION === 8) { + this.migrateFromV4ToV5() + this.migrateFromV5ToV6() + this.migrateFromV6ToV7() + this.migrateFromV7ToV8() + this.setUserVersion(SCHEMA_VERSION) + return + } + if (currentVersion !== SCHEMA_VERSION) { throw this.buildSchemaMismatchError(currentVersion) } @@ -179,6 +221,7 @@ export class Store { model TEXT, model_reasoning_effort TEXT, effort TEXT, + service_tier TEXT, todos TEXT, todos_updated_at INTEGER, team_state TEXT, @@ -362,6 +405,13 @@ export class Store { } } + private migrateFromV7ToV8(): void { + const columns = this.getSessionColumnNames() + if (!columns.has('service_tier')) { + this.db.exec('ALTER TABLE sessions ADD COLUMN service_tier TEXT') + } + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bb850c0c37..4c357e7079 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -126,7 +126,7 @@ export function mergeSessionMessages( const newMaxSeq = getMaxSeq(db, toSessionId) try { - db.exec('BEGIN') + db.exec('SAVEPOINT merge_session_messages') if (newMaxSeq > 0 && oldMaxSeq > 0) { db.prepare( @@ -154,10 +154,11 @@ export function mergeSessionMessages( 'UPDATE messages SET session_id = ? WHERE session_id = ?' ).run(toSessionId, fromSessionId) - db.exec('COMMIT') + db.exec('RELEASE SAVEPOINT merge_session_messages') return { moved: result.changes, oldMaxSeq, newMaxSeq } } catch (error) { - db.exec('ROLLBACK') + db.exec('ROLLBACK TO SAVEPOINT merge_session_messages') + db.exec('RELEASE SAVEPOINT merge_session_messages') throw error } } diff --git a/hub/src/store/sessionStore.ts b/hub/src/store/sessionStore.ts index c6af8ad4e6..9356ae7d31 100644 --- a/hub/src/store/sessionStore.ts +++ b/hub/src/store/sessionStore.ts @@ -11,6 +11,7 @@ import { setSessionEffort, setSessionModel, setSessionModelReasoningEffort, + setSessionServiceTier, setSessionTeamState, setSessionTodos, updateSessionAgentState, @@ -31,9 +32,10 @@ export class SessionStore { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + serviceTier?: string ): StoredSession { - return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + return getOrCreateSession(this.db, tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, serviceTier) } updateSessionMetadata( @@ -80,6 +82,10 @@ export class SessionStore { return setSessionEffort(this.db, id, effort, namespace, options) } + setSessionServiceTier(id: string, serviceTier: string | null, namespace: string, options?: { touchUpdatedAt?: boolean }): boolean { + return setSessionServiceTier(this.db, id, serviceTier, namespace, options) + } + getSession(id: string): StoredSession | null { return getSession(this.db, id) } diff --git a/hub/src/store/sessions.ts b/hub/src/store/sessions.ts index 95a33c5acd..e88f170ad9 100644 --- a/hub/src/store/sessions.ts +++ b/hub/src/store/sessions.ts @@ -19,6 +19,7 @@ type DbSessionRow = { model: string | null model_reasoning_effort: string | null effort: string | null + service_tier: string | null todos: string | null todos_updated_at: number | null team_state: string | null @@ -43,6 +44,7 @@ function toStoredSession(row: DbSessionRow): StoredSession { model: row.model, modelReasoningEffort: row.model_reasoning_effort, effort: row.effort, + serviceTier: row.service_tier, todos: safeJsonParse(row.todos), todosUpdatedAt: row.todos_updated_at, teamState: safeJsonParse(row.team_state), @@ -61,7 +63,8 @@ export function getOrCreateSession( namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + serviceTier?: string ): StoredSession { const existing = db.prepare( 'SELECT * FROM sessions WHERE tag = ? AND namespace = ? ORDER BY created_at DESC LIMIT 1' @@ -85,6 +88,7 @@ export function getOrCreateSession( model, model_reasoning_effort, effort, + service_tier, todos, todos_updated_at, active, active_at, seq ) VALUES ( @@ -94,6 +98,7 @@ export function getOrCreateSession( @model, @model_reasoning_effort, @effort, + @service_tier, NULL, NULL, 0, NULL, 0 ) @@ -107,7 +112,8 @@ export function getOrCreateSession( agent_state: agentStateJson, model: model ?? null, model_reasoning_effort: modelReasoningEffort ?? null, - effort: effort ?? null + effort: effort ?? null, + service_tier: serviceTier ?? null }) const row = getSession(db, id) @@ -342,6 +348,39 @@ export function setSessionEffort( } } +export function setSessionServiceTier( + db: Database, + id: string, + serviceTier: string | null, + namespace: string, + options?: { touchUpdatedAt?: boolean } +): boolean { + const now = Date.now() + const touchUpdatedAt = options?.touchUpdatedAt === true + + try { + const result = db.prepare(` + UPDATE sessions + SET service_tier = @service_tier, + updated_at = CASE WHEN @touch_updated_at = 1 THEN @updated_at ELSE updated_at END, + seq = seq + 1 + WHERE id = @id + AND namespace = @namespace + AND service_tier IS NOT @service_tier + `).run({ + id, + namespace, + service_tier: serviceTier, + updated_at: now, + touch_updated_at: touchUpdatedAt ? 1 : 0 + }) + + return result.changes === 1 + } catch { + return false + } +} + export function getSession(db: Database, id: string): StoredSession | null { const row = db.prepare('SELECT * FROM sessions WHERE id = ?').get(id) as DbSessionRow | undefined return row ? toStoredSession(row) : null diff --git a/hub/src/store/types.ts b/hub/src/store/types.ts index 9297a5e64d..ed4f3d9ad3 100644 --- a/hub/src/store/types.ts +++ b/hub/src/store/types.ts @@ -12,6 +12,7 @@ export type StoredSession = { model: string | null modelReasoningEffort: string | null effort: string | null + serviceTier: string | null todos: unknown | null todosUpdatedAt: number | null teamState: unknown | null diff --git a/hub/src/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts new file mode 100644 index 0000000000..3aed31284b --- /dev/null +++ b/hub/src/sync/rpcGateway.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from 'bun:test' +import { RpcGateway } from './rpcGateway' +import { RpcRegistry } from '../socket/rpcRegistry' + +describe('RpcGateway', () => { + it('sends list-importable-sessions rpc requests and parses the response shape', async () => { + const registry = new RpcRegistry() + const captured: Array<{ event: string; payload: { method: string; params: string } }> = [] + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async (event: string, payload: { method: string; params: string }) => { + captured.push({ event, payload }) + return { + sessions: [ + { + agent: 'codex', + externalSessionId: 'session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + model: 'gpt-5.4', + effort: 'xhigh', + modelReasoningEffort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default', + approvalPolicy: 'never', + sandboxPolicy: { type: 'danger-full-access' }, + permissionMode: 'yolo', + ignoredField: 'strip-me' + } + ], + ignoredResponseField: true + } + } + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).resolves.toEqual({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + model: 'gpt-5.4', + effort: 'xhigh', + modelReasoningEffort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default', + approvalPolicy: 'never', + sandboxPolicy: { type: 'danger-full-access' }, + permissionMode: 'yolo' + } + ] + }) + expect(captured).toEqual([ + { + event: 'rpc-request', + payload: { + method: 'machine-1:list-importable-sessions', + params: JSON.stringify({ agent: 'codex' }) + } + } + ]) + }) + + it('parses claude list-importable-sessions responses', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/tmp/claude-session-1.jsonl', + previewTitle: 'Fix the API', + previewPrompt: 'Please fix the API' + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'claude' })).resolves.toEqual({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/work/project', + timestamp: 1712131200000, + transcriptPath: '/tmp/claude-session-1.jsonl', + previewTitle: 'Fix the API', + previewPrompt: 'Please fix the API' + } + ] + }) + }) + + it('rejects malformed list-importable-sessions responses', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 123, + cwd: '/tmp/project', + timestamp: 'not-a-number', + transcriptPath: '/tmp/project/.codex/sessions/session-1.jsonl', + previewTitle: null, + previewPrompt: null + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).rejects.toThrow() + }) + + it('rejects list-importable-sessions responses whose session agent does not match the request agent', async () => { + const registry = new RpcRegistry() + + const socket = { + id: 'socket-1', + timeout: () => ({ + emitWithAck: async () => ({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-session-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/session-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + }) + } + + registry.register(socket as never, 'machine-1:list-importable-sessions') + + const io = { + of: () => ({ + sockets: new Map([['socket-1', socket]]) + }) + } + + const gateway = new RpcGateway(io as never, registry) + + await expect(gateway.listImportableSessions('machine-1', { agent: 'codex' })).rejects.toThrow( + 'Unexpected importable session agent "claude" for request "codex"' + ) + }) +}) diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index d3fe5ee2f3..e5de852f64 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,7 +1,43 @@ import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' +import type { + RpcListImportableSessionsRequest, + RpcListImportableSessionsResponse +} from '@hapi/protocol/rpcTypes' import type { Server } from 'socket.io' +import { z } from 'zod' import type { RpcRegistry } from '../socket/rpcRegistry' +const importableCodexSessionSummarySchema = z.object({ + agent: z.union([z.literal('codex'), z.literal('claude')]), + externalSessionId: z.string(), + cwd: z.string().nullable(), + timestamp: z.number().nullable(), + transcriptPath: z.string(), + previewTitle: z.string().nullable(), + previewPrompt: z.string().nullable(), + model: z.string().nullable().optional(), + effort: z.string().nullable().optional(), + modelReasoningEffort: z.string().nullable().optional(), + collaborationMode: z.union([z.literal('default'), z.literal('plan')]).nullable().optional(), + approvalPolicy: z.string().nullable().optional(), + sandboxPolicy: z.unknown().nullable().optional(), + serviceTier: z.string().nullable().optional(), + permissionMode: z.union([ + z.literal('default'), + z.literal('acceptEdits'), + z.literal('bypassPermissions'), + z.literal('plan'), + z.literal('ask'), + z.literal('read-only'), + z.literal('safe-yolo'), + z.literal('yolo') + ]).nullable().optional() +}) + +const listImportableSessionsResponseSchema = z.object({ + sessions: z.array(importableCodexSessionSummarySchema) +}) + export type RpcCommandResponse = { success: boolean stdout?: string @@ -96,6 +132,7 @@ export class RpcGateway { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { @@ -117,13 +154,14 @@ export class RpcGateway { worktreeName?: string, resumeSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + serviceTier?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } + { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode, serviceTier } ) if (result && typeof result === 'object') { const obj = result as Record @@ -232,6 +270,20 @@ export class RpcGateway { } } + async listImportableSessions( + machineId: string, + request: RpcListImportableSessionsRequest + ): Promise { + const response = await this.machineRpc(machineId, 'list-importable-sessions', request) + const parsed = listImportableSessionsResponseSchema.parse(response) + for (const session of parsed.sessions) { + if (session.agent !== request.agent) { + throw new Error(`Unexpected importable session agent "${session.agent}" for request "${request.agent}"`) + } + } + return parsed + } + private async sessionRpc(sessionId: string, method: string, params: unknown): Promise { return await this.rpcCall(`${sessionId}:${method}`, params) } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index be42edf68d..ad5d05896f 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -39,6 +39,33 @@ export class SessionCache { return session } + findSessionByExternalCodexSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.findSessionByMetadataSessionId(namespace, 'codexSessionId', externalSessionId) + } + + findSessionByExternalClaudeSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.findSessionByMetadataSessionId(namespace, 'claudeSessionId', externalSessionId) + } + + private findSessionByMetadataSessionId( + namespace: string, + key: 'codexSessionId' | 'claudeSessionId', + externalSessionId: string + ): { sessionId: string } | null { + for (const stored of this.store.sessions.getSessionsByNamespace(namespace)) { + const metadata = MetadataSchema.safeParse(stored.metadata) + if (!metadata.success) { + continue + } + + if (metadata.data[key] === externalSessionId) { + return { sessionId: stored.id } + } + } + + return null + } + resolveSessionAccess( sessionId: string, namespace: string @@ -65,9 +92,10 @@ export class SessionCache { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + serviceTier?: string ): Session { - const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + const stored = this.store.sessions.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, serviceTier) return this.refreshSession(stored.id) ?? (() => { throw new Error('Failed to load session') })() } @@ -141,6 +169,7 @@ export class SessionCache { model: stored.model, modelReasoningEffort: stored.modelReasoningEffort, effort: stored.effort, + serviceTier: stored.serviceTier, permissionMode: existing?.permissionMode, collaborationMode: existing?.collaborationMode } @@ -166,6 +195,7 @@ export class SessionCache { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode }): void { const t = clampAliveTime(payload.time) @@ -180,6 +210,7 @@ export class SessionCache { const previousModel = session.model const previousModelReasoningEffort = session.modelReasoningEffort const previousEffort = session.effort + const previousServiceTier = session.serviceTier const previousCollaborationMode = session.collaborationMode session.active = true @@ -213,6 +244,14 @@ export class SessionCache { } session.effort = payload.effort } + if (payload.serviceTier !== undefined) { + if (payload.serviceTier !== session.serviceTier) { + this.store.sessions.setSessionServiceTier(payload.sid, payload.serviceTier, session.namespace, { + touchUpdatedAt: false + }) + } + session.serviceTier = payload.serviceTier + } if (payload.collaborationMode !== undefined) { session.collaborationMode = payload.collaborationMode } @@ -223,6 +262,7 @@ export class SessionCache { || previousModel !== session.model || previousModelReasoningEffort !== session.modelReasoningEffort || previousEffort !== session.effort + || previousServiceTier !== session.serviceTier || previousCollaborationMode !== session.collaborationMode const shouldBroadcast = (!wasActive && session.active) || (wasThinking !== session.thinking) @@ -242,6 +282,7 @@ export class SessionCache { model: session.model, modelReasoningEffort: session.modelReasoningEffort, effort: session.effort, + serviceTier: session.serviceTier, collaborationMode: session.collaborationMode } }) @@ -305,6 +346,7 @@ export class SessionCache { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): void { @@ -349,6 +391,17 @@ export class SessionCache { } session.effort = config.effort } + if (config.serviceTier !== undefined) { + if (config.serviceTier !== session.serviceTier) { + const updated = this.store.sessions.setSessionServiceTier(sessionId, config.serviceTier, session.namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to update session service tier') + } + } + session.serviceTier = config.serviceTier + } if (config.collaborationMode !== undefined) { session.collaborationMode = config.collaborationMode } @@ -494,6 +547,15 @@ export class SessionCache { } } + if (newStored.serviceTier === null && oldStored.serviceTier !== null) { + const updated = this.store.sessions.setSessionServiceTier(newSessionId, oldStored.serviceTier, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session service tier during merge') + } + } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { this.store.sessions.setSessionTodos( newSessionId, diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 37a9f3c97e..6d47c4cf70 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -64,13 +64,45 @@ describe('session model', () => { 'default', 'gpt-5.4', undefined, - 'xhigh' + 'xhigh', + 'fast' ) expect(session.modelReasoningEffort).toBe('xhigh') expect(store.sessions.getSession(session.id)?.modelReasoningEffort).toBe('xhigh') }) + it('includes Codex runtime config in session summaries', () => { + const store = new Store(':memory:') + const events: SyncEvent[] = [] + const cache = new SessionCache(store, createPublisher(events)) + + const session = cache.getOrCreateSession( + 'session-codex-config-summary', + { path: '/tmp/project', host: 'localhost', flavor: 'codex' }, + null, + 'default', + 'gpt-5.4', + undefined, + 'xhigh', + 'fast' + ) + + cache.applySessionConfig(session.id, { + permissionMode: 'yolo', + collaborationMode: 'default' + }) + + expect(toSessionSummary(session)).toMatchObject({ + model: 'gpt-5.4', + modelReasoningEffort: 'xhigh', + effort: null, + serviceTier: 'fast', + permissionMode: 'yolo', + collaborationMode: 'default' + }) + }) + it('preserves model from old session when merging into resumed session', async () => { const store = new Store(':memory:') const events: SyncEvent[] = [] @@ -507,6 +539,96 @@ describe('session model', () => { } }) + it('passes importable Codex config when importing an existing session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedModel: string | undefined + let capturedModelReasoningEffort: string | undefined + let capturedPermissionMode: string | undefined + let capturedServiceTier: string | undefined + let capturedConfig: unknown + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: null, + model: 'gpt-5.4', + modelReasoningEffort: 'xhigh', + effort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default', + permissionMode: 'yolo' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + model?: string, + modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: string, + _worktreeName?: string, + _resumeSessionId?: string, + _effort?: string, + permissionMode?: string, + serviceTier?: string + ) => { + capturedModel = model + capturedModelReasoningEffort = modelReasoningEffort + capturedPermissionMode = permissionMode + capturedServiceTier = serviceTier + return { type: 'success', sessionId: 'hapi-imported-1' } + } + ;(engine as any).rpcGateway.requestSessionConfig = async (_sessionId: string, config: unknown) => { + capturedConfig = config + return { + applied: config + } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ type: 'success', sessionId: 'hapi-imported-1' }) + expect(capturedModel).toBe('gpt-5.4') + expect(capturedModelReasoningEffort).toBe('xhigh') + expect(capturedPermissionMode).toBe('yolo') + expect(capturedServiceTier).toBe('fast') + expect(capturedConfig).toEqual({ + model: 'gpt-5.4', + modelReasoningEffort: 'xhigh', + effort: 'xhigh', + serviceTier: 'fast', + permissionMode: 'yolo', + collaborationMode: 'default' + }) + } finally { + engine.stop() + } + }) + describe('session dedup by agent session ID', () => { it('merges duplicate when codexSessionId collides', async () => { const store = new Store(':memory:') diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 04ac098310..33e779c4da 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -8,6 +8,7 @@ */ import type { CodexCollaborationMode, DecryptedMessage, PermissionMode, Session, SyncEvent } from '@hapi/protocol/types' +import type { RpcListImportableSessionsResponse } from '@hapi/protocol/rpcTypes' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -42,8 +43,37 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +type ImportExternalAgentSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'import_failed' } + +type RefreshExternalAgentSessionResult = + | { type: 'success'; sessionId: string } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'resume_unavailable' | 'refresh_failed' } + +type ListImportableAgentSessionsResult = + | { type: 'success'; machineId: string; sessions: RpcListImportableSessionsResponse['sessions'] } + | { type: 'error'; message: string; code: 'no_machine_online' | 'importable_sessions_failed' } + +type SessionConfigForSpawn = { + model?: string | null + modelReasoningEffort?: string | null + effort?: string | null + serviceTier?: string | null + permissionMode?: PermissionMode + collaborationMode?: CodexCollaborationMode +} + +export type ImportExternalCodexSessionResult = ImportExternalAgentSessionResult +export type ImportExternalClaudeSessionResult = ImportExternalAgentSessionResult +export type RefreshExternalCodexSessionResult = RefreshExternalAgentSessionResult +export type RefreshExternalClaudeSessionResult = RefreshExternalAgentSessionResult +export type ListImportableCodexSessionsResult = ListImportableAgentSessionsResult +export type ListImportableClaudeSessionsResult = ListImportableAgentSessionsResult + export class SyncEngine { private readonly eventPublisher: EventPublisher + private readonly store: Store private readonly sessionCache: SessionCache private readonly machineCache: MachineCache private readonly messageService: MessageService @@ -56,6 +86,7 @@ export class SyncEngine { rpcRegistry: RpcRegistry, sseManager: SSEManager ) { + this.store = store this.eventPublisher = new EventPublisher(sseManager, (event) => this.resolveNamespace(event)) this.sessionCache = new SessionCache(store, this.eventPublisher) this.machineCache = new MachineCache(store, this.eventPublisher) @@ -145,6 +176,30 @@ export class SyncEngine { return this.machineCache.getOnlineMachinesByNamespace(namespace) } + findSessionByExternalCodexSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.sessionCache.findSessionByExternalCodexSessionId(namespace, externalSessionId) + } + + findSessionByExternalClaudeSessionId(namespace: string, externalSessionId: string): { sessionId: string } | null { + return this.sessionCache.findSessionByExternalClaudeSessionId(namespace, externalSessionId) + } + + async importExternalCodexSession(externalSessionId: string, namespace: string): Promise { + return await this.importExternalSession(externalSessionId, namespace, 'codex') + } + + async importExternalClaudeSession(externalSessionId: string, namespace: string): Promise { + return await this.importExternalSession(externalSessionId, namespace, 'claude') + } + + async refreshExternalCodexSession(externalSessionId: string, namespace: string): Promise { + return await this.refreshExternalSession(externalSessionId, namespace, 'codex') + } + + async refreshExternalClaudeSession(externalSessionId: string, namespace: string): Promise { + return await this.refreshExternalSession(externalSessionId, namespace, 'claude') + } + getMessagesPage(sessionId: string, options: { limit: number; beforeSeq: number | null }): { messages: DecryptedMessage[] page: { @@ -200,6 +255,7 @@ export class SyncEngine { modelReasoningEffort?: string | null effort?: string | null collaborationMode?: CodexCollaborationMode + serviceTier?: string | null }): void { this.sessionCache.handleSessionAlive(payload) this.triggerDedupIfNeeded(payload.sid) @@ -246,9 +302,10 @@ export class SyncEngine { namespace: string, model?: string, effort?: string, - modelReasoningEffort?: string + modelReasoningEffort?: string, + serviceTier?: string ): Session { - return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort) + return this.sessionCache.getOrCreateSession(tag, metadata, agentState, namespace, model, effort, modelReasoningEffort, serviceTier) } getOrCreateMachine(id: string, metadata: unknown, runnerState: unknown, namespace: string): Machine { @@ -321,6 +378,7 @@ export class SyncEngine { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode } ): Promise { @@ -334,6 +392,7 @@ export class SyncEngine { model?: Session['model'] modelReasoningEffort?: Session['modelReasoningEffort'] effort?: Session['effort'] + serviceTier?: Session['serviceTier'] collaborationMode?: Session['collaborationMode'] } } @@ -356,7 +415,8 @@ export class SyncEngine { worktreeName?: string, resumeSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + serviceTier?: string ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -369,7 +429,8 @@ export class SyncEngine { worktreeName, resumeSessionId, effort, - permissionMode + permissionMode, + serviceTier ) } @@ -442,7 +503,8 @@ export class SyncEngine { undefined, resumeToken, session.effort ?? undefined, - session.permissionMode ?? undefined + session.permissionMode ?? undefined, + session.serviceTier ?? undefined ) if (spawnResult.type !== 'success') { @@ -472,6 +534,14 @@ export class SyncEngine { return { type: 'success', sessionId: spawnResult.sessionId } } + async listImportableCodexSessions(namespace: string): Promise { + return await this.listImportableSessionsByAgent(namespace, 'codex') + } + + async listImportableClaudeSessions(namespace: string): Promise { + return await this.listImportableSessionsByAgent(namespace, 'claude') + } + private hasSameAgentSessionIds( prev: Session['metadata'] | null, next: NonNullable @@ -504,6 +574,462 @@ export class SyncEngine { return false } + async waitForSessionSettled( + sessionId: string, + timeoutMs: number = 15_000, + stableMs: number = 800 + ): Promise { + const start = Date.now() + let lastSeq = -1 + let lastThinking: boolean | null = null + let lastChangeAt = Date.now() + + while (Date.now() - start < timeoutMs) { + const session = this.getSession(sessionId) + if (!session?.active) { + await new Promise((resolve) => setTimeout(resolve, 250)) + continue + } + + const latestMessage = this.store.messages.getMessages(sessionId, 1).at(-1) + const latestSeq = latestMessage?.seq ?? 0 + if (latestSeq !== lastSeq || session.thinking !== lastThinking) { + lastSeq = latestSeq + lastThinking = session.thinking + lastChangeAt = Date.now() + } + + if (!session.thinking && Date.now() - lastChangeAt >= stableMs) { + return true + } + + await new Promise((resolve) => setTimeout(resolve, 250)) + } + + return false + } + + private getImportableAgentLabel(agent: 'codex' | 'claude'): 'Codex' | 'Claude' { + return agent === 'codex' ? 'Codex' : 'Claude' + } + + private findSessionByExternalSessionId( + namespace: string, + externalSessionId: string, + agent: 'codex' | 'claude' + ): { sessionId: string } | null { + return agent === 'codex' + ? this.findSessionByExternalCodexSessionId(namespace, externalSessionId) + : this.findSessionByExternalClaudeSessionId(namespace, externalSessionId) + } + + private async importExternalSession( + externalSessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): Promise { + const existing = this.findSessionByExternalSessionId(namespace, externalSessionId, agent) + if (existing) { + return { type: 'success', sessionId: existing.sessionId } + } + + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) + if (sourceResult.type === 'error') { + return { + type: 'error', + message: sourceResult.message, + code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' + ? sourceResult.code + : 'import_failed' + } + } + + const cwd = sourceResult.session.cwd + if (typeof cwd !== 'string' || cwd.length === 0) { + return { + type: 'error', + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, + code: 'import_failed' + } + } + + const sourceConfig = this.getImportableSessionConfig(sourceResult.session, agent) + const spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + agent, + this.stringOrUndefined(sourceConfig.model), + this.stringOrUndefined(sourceConfig.modelReasoningEffort), + undefined, + undefined, + undefined, + externalSessionId, + this.stringOrUndefined(sourceConfig.effort), + sourceConfig.permissionMode, + this.stringOrUndefined(sourceConfig.serviceTier) + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'import_failed' + } + } + + if (!(await this.waitForSessionSettled(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'import_failed' + } + } + + await this.applyImportedSessionConfig(spawnResult.sessionId, sourceConfig) + + const importedTitle = this.getBestImportableSessionTitle(sourceResult.session) + await this.applyImportableSessionTitle(spawnResult.sessionId, importedTitle) + + return { type: 'success', sessionId: spawnResult.sessionId } + } + + private async refreshExternalSession( + externalSessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): Promise { + const existing = this.findSessionByExternalSessionId(namespace, externalSessionId, agent) + if (!existing) { + return { + type: 'error', + message: 'Imported session not found', + code: 'session_not_found' + } + } + + const access = this.sessionCache.resolveSessionAccess(existing.sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Imported session not found', + code: 'session_not_found' + } + } + + const session = access.session + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) + if (sourceResult.type === 'error') { + return { + type: 'error', + message: sourceResult.message, + code: sourceResult.code === 'no_machine_online' || sourceResult.code === 'session_not_found' + ? sourceResult.code + : 'refresh_failed' + } + } + + const cwd = sourceResult.session.cwd + if (typeof cwd !== 'string' || cwd.length === 0) { + return { + type: 'error', + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, + code: 'refresh_failed' + } + } + + const sourceConfig = this.getImportableSessionConfig(sourceResult.session, agent) + const storedConfig = this.getStoredSessionConfig(session) + const spawnConfig = { + ...storedConfig, + ...sourceConfig + } + const spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + agent, + this.stringOrUndefined(spawnConfig.model), + this.stringOrUndefined(spawnConfig.modelReasoningEffort), + undefined, + undefined, + undefined, + externalSessionId, + this.stringOrUndefined(spawnConfig.effort), + spawnConfig.permissionMode, + this.stringOrUndefined(spawnConfig.serviceTier) + ) + + if (spawnResult.type !== 'success') { + return { + type: 'error', + message: spawnResult.message, + code: 'refresh_failed' + } + } + + if (!(await this.waitForSessionSettled(spawnResult.sessionId))) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: 'Session failed to become active', + code: 'refresh_failed' + } + } + + const importedTitle = this.getBestImportableSessionTitle(sourceResult.session) + await this.applyImportableSessionTitle(spawnResult.sessionId, importedTitle) + + await this.applyImportedSessionConfig(spawnResult.sessionId, spawnConfig) + + if (spawnResult.sessionId !== access.sessionId) { + try { + this.detachExternalSessionMapping(access.sessionId, namespace, agent) + } catch (error) { + this.discardSpawnedSession(spawnResult.sessionId, namespace) + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to replace imported session', + code: 'refresh_failed' + } + } + } + + return { type: 'success', sessionId: spawnResult.sessionId } + } + + private async listImportableSessionsByAgent( + namespace: string, + agent: 'codex' | 'claude' + ): Promise { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + const targetMachine = onlineMachines[0] + if (!targetMachine) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + try { + const response = await this.rpcGateway.listImportableSessions(targetMachine.id, { agent }) + return { + type: 'success', + machineId: targetMachine.id, + sessions: response.sessions + } + } catch (error) { + return { + type: 'error', + message: error instanceof Error ? error.message : 'Failed to list importable sessions', + code: 'importable_sessions_failed' + } + } + } + + private async findImportableSessionSource( + namespace: string, + externalSessionId: string, + agent: 'codex' | 'claude' + ): Promise< + | { type: 'success'; machineId: string; session: RpcListImportableSessionsResponse['sessions'][number] } + | { type: 'error'; message: string; code: 'session_not_found' | 'no_machine_online' | 'importable_sessions_failed' } + > { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return { + type: 'error', + message: 'No machine online', + code: 'no_machine_online' + } + } + + let lastError: string | null = null + for (const machine of onlineMachines) { + try { + const response = await this.rpcGateway.listImportableSessions(machine.id, { agent }) + const session = response.sessions.find((item) => item.externalSessionId === externalSessionId) + if (session) { + if (typeof session.cwd !== 'string' || session.cwd.length === 0) { + return { + type: 'error', + message: `Importable ${this.getImportableAgentLabel(agent)} session is missing cwd`, + code: 'importable_sessions_failed' + } + } + return { + type: 'success', + machineId: machine.id, + session + } + } + } catch (error) { + lastError = error instanceof Error ? error.message : 'Failed to list importable sessions' + } + } + + return { + type: 'error', + message: lastError ?? `Importable ${this.getImportableAgentLabel(agent)} session not found`, + code: lastError ? 'importable_sessions_failed' : 'session_not_found' + } + } + + private getBestImportableSessionTitle( + session: RpcListImportableSessionsResponse['sessions'][number] + ): string | null { + const previewTitle = typeof session.previewTitle === 'string' ? session.previewTitle.trim() : '' + if (previewTitle.length > 0) { + return previewTitle + } + + const previewPrompt = typeof session.previewPrompt === 'string' ? session.previewPrompt.trim() : '' + if (previewPrompt.length > 0) { + return previewPrompt + } + + return null + } + + private getStoredSessionConfig(session: Session): SessionConfigForSpawn { + return { + model: session.model, + modelReasoningEffort: session.modelReasoningEffort, + effort: session.effort, + serviceTier: session.serviceTier, + permissionMode: session.permissionMode, + collaborationMode: session.collaborationMode + } + } + + private getImportableSessionConfig( + session: RpcListImportableSessionsResponse['sessions'][number], + agent: 'codex' | 'claude' + ): SessionConfigForSpawn { + if (agent !== 'codex') { + return {} + } + + const raw = session as Record + const config: SessionConfigForSpawn = {} + if (Object.hasOwn(raw, 'model') && (typeof raw.model === 'string' || raw.model === null)) { + config.model = raw.model + } + if ( + Object.hasOwn(raw, 'modelReasoningEffort') + && (typeof raw.modelReasoningEffort === 'string' || raw.modelReasoningEffort === null) + ) { + config.modelReasoningEffort = raw.modelReasoningEffort + } else if (Object.hasOwn(raw, 'effort') && typeof raw.effort === 'string') { + config.modelReasoningEffort = raw.effort + } + if (Object.hasOwn(raw, 'effort') && (typeof raw.effort === 'string' || raw.effort === null)) { + config.effort = raw.effort + } + if (Object.hasOwn(raw, 'serviceTier') && (typeof raw.serviceTier === 'string' || raw.serviceTier === null)) { + config.serviceTier = raw.serviceTier + } + if (this.isPermissionMode(raw.permissionMode)) { + config.permissionMode = raw.permissionMode + } + if (raw.collaborationMode === 'default' || raw.collaborationMode === 'plan') { + config.collaborationMode = raw.collaborationMode + } + return config + } + + private stringOrUndefined(value: string | null | undefined): string | undefined { + return typeof value === 'string' && value.length > 0 ? value : undefined + } + + private isPermissionMode(value: unknown): value is PermissionMode { + return typeof value === 'string' + && ( + value === 'default' + || value === 'acceptEdits' + || value === 'bypassPermissions' + || value === 'plan' + || value === 'ask' + || value === 'read-only' + || value === 'safe-yolo' + || value === 'yolo' + ) + } + + private async applyImportedSessionConfig(sessionId: string, config: SessionConfigForSpawn): Promise { + if (Object.keys(config).length === 0) { + return + } + + await this.applySessionConfig(sessionId, config) + } + + private async applyImportableSessionTitle(sessionId: string, title: string | null): Promise { + if (!title) { + return + } + + const session = this.getSession(sessionId) ?? this.sessionCache.refreshSession(sessionId) + if (!session) { + return + } + + if (session.metadata?.name === title) { + return + } + + try { + await this.sessionCache.renameSession(sessionId, title) + } catch { + // Best effort. Import/refresh must not fail just because the title write raced. + } + } + + private detachExternalSessionMapping( + sessionId: string, + namespace: string, + agent: 'codex' | 'claude' + ): void { + const session = this.getSessionByNamespace(sessionId, namespace) + if (!session?.metadata) { + return + } + + const nextMetadata = { ...session.metadata } + if (agent === 'codex') { + delete nextMetadata.codexSessionId + } else { + delete nextMetadata.claudeSessionId + } + + const update = (metadataVersion: number): boolean => { + const result = this.store.sessions.updateSessionMetadata( + sessionId, + nextMetadata, + metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + return result.result === 'success' + } + + if (!update(session.metadataVersion)) { + const refreshed = this.sessionCache.refreshSession(sessionId) + if (!refreshed || !update(refreshed.metadataVersion)) { + throw new Error('Failed to detach old imported session mapping') + } + } + + this.sessionCache.refreshSession(sessionId) + } + + private discardSpawnedSession(sessionId: string, namespace: string): void { + const deleted = this.store.sessions.deleteSession(sessionId, namespace) + if (deleted) { + this.sessionCache.refreshSession(sessionId) + } + } + async checkPathsExist(machineId: string, paths: string[]): Promise> { return await this.rpcGateway.checkPathsExist(machineId, paths) } diff --git a/hub/src/web/routes/cli.ts b/hub/src/web/routes/cli.ts index f464b99b75..a794aec282 100644 --- a/hub/src/web/routes/cli.ts +++ b/hub/src/web/routes/cli.ts @@ -14,7 +14,8 @@ const createOrLoadSessionSchema = z.object({ agentState: z.unknown().nullable().optional(), model: z.string().optional(), modelReasoningEffort: z.string().optional(), - effort: z.string().optional() + effort: z.string().optional(), + serviceTier: z.string().optional() }) const createOrLoadMachineSchema = z.object({ @@ -110,7 +111,8 @@ export function createCliRoutes(getSyncEngine: () => SyncEngine | null): Hono) { + const app = new Hono() + app.use('*', async (c, next) => { + c.set('namespace', 'default') + await next() + }) + app.route('/api', createImportableSessionsRoutes(() => engine as SyncEngine)) + return app +} + +describe('importable sessions routes', () => { + it('lists codex importable sessions with imported status', async () => { + const engine = { + listImportableCodexSessions: async () => ({ + type: 'success' as const, + machineId: 'machine-1', + sessions: [ + { + agent: 'codex' as const, + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/external-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + model: 'gpt-5.4', + effort: 'xhigh', + modelReasoningEffort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default' as const, + approvalPolicy: 'never', + sandboxPolicy: { type: 'danger-full-access' }, + permissionMode: 'yolo' as const + }, + { + agent: 'codex' as const, + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/.codex/sessions/external-2.jsonl', + previewTitle: null, + previewPrompt: null + } + ] + }), + findSessionByExternalCodexSessionId: (_namespace: string, externalSessionId: string) => { + if (externalSessionId === 'external-1') { + return { sessionId: 'hapi-123' } + } + return null + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=codex') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/external-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt', + model: 'gpt-5.4', + effort: 'xhigh', + modelReasoningEffort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default', + approvalPolicy: 'never', + sandboxPolicy: { type: 'danger-full-access' }, + permissionMode: 'yolo', + alreadyImported: true, + importedHapiSessionId: 'hapi-123' + }, + { + agent: 'codex', + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/.codex/sessions/external-2.jsonl', + previewTitle: null, + previewPrompt: null, + alreadyImported: false, + importedHapiSessionId: null + } + ] + }) + }) + + it('returns a sensible error when no machine is online', async () => { + const engine = { + listImportableCodexSessions: async () => ({ + type: 'error' as const, + code: 'no_machine_online' as const, + message: 'No machine online' + }) + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=codex') + + expect(response.status).toBe(503) + expect(await response.json()).toEqual({ + error: 'No machine online', + code: 'no_machine_online' + }) + }) + + it('lists claude importable sessions with imported status', async () => { + const engine = { + listImportableClaudeSessions: async () => ({ + type: 'success' as const, + machineId: 'machine-1', + sessions: [ + { + agent: 'claude' as const, + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/external-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt' + } + ] + }), + findSessionByExternalClaudeSessionId: () => ({ sessionId: 'hapi-claude-123' }) + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions?agent=claude') + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + sessions: [ + { + agent: 'claude', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/external-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt', + alreadyImported: true, + importedHapiSessionId: 'hapi-claude-123' + } + ] + }) + }) + + it('imports an external codex session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + importExternalCodexSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/codex/external-1/import', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) + + it('re-imports an external codex session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + refreshExternalCodexSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/codex/external-1/refresh', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) + + it('imports an external claude session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + importExternalClaudeSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-claude-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/claude/external-1/import', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-claude-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) + + it('re-imports an external claude session', async () => { + const captured: Array<{ externalSessionId: string; namespace: string }> = [] + const engine = { + refreshExternalClaudeSession: async (externalSessionId: string, namespace: string) => { + captured.push({ externalSessionId, namespace }) + return { + type: 'success' as const, + sessionId: 'hapi-claude-123' + } + } + } + + const app = createApp(engine) + + const response = await app.request('/api/importable-sessions/claude/external-1/refresh', { + method: 'POST' + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + type: 'success', + sessionId: 'hapi-claude-123' + }) + expect(captured).toEqual([ + { + externalSessionId: 'external-1', + namespace: 'default' + } + ]) + }) +}) diff --git a/hub/src/web/routes/importableSessions.ts b/hub/src/web/routes/importableSessions.ts new file mode 100644 index 0000000000..6c5e45b96b --- /dev/null +++ b/hub/src/web/routes/importableSessions.ts @@ -0,0 +1,122 @@ +import { Hono } from 'hono' +import { z } from 'zod' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { requireSyncEngine } from './guards' + +const querySchema = z.object({ + agent: z.union([z.literal('codex'), z.literal('claude')]) +}) + +export function createImportableSessionsRoutes(getSyncEngine: () => SyncEngine | null): Hono { + const app = new Hono() + + function mapActionErrorStatus(code: string): number { + if (code === 'no_machine_online') return 503 + if (code === 'session_not_found') return 404 + if (code === 'access_denied') return 403 + return 500 + } + + app.get('/importable-sessions', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const parsed = querySchema.safeParse({ + agent: c.req.query('agent') + }) + if (!parsed.success) { + return c.json({ error: 'Invalid agent' }, 400) + } + + const namespace = c.get('namespace') + const result = parsed.data.agent === 'codex' + ? await engine.listImportableCodexSessions(namespace) + : await engine.listImportableClaudeSessions(namespace) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + const sessions = result.sessions.map((session) => { + const existing = parsed.data.agent === 'codex' + ? engine.findSessionByExternalCodexSessionId(namespace, session.externalSessionId) + : engine.findSessionByExternalClaudeSessionId(namespace, session.externalSessionId) + return { + ...session, + alreadyImported: Boolean(existing), + importedHapiSessionId: existing?.sessionId ?? null + } + }) + + return c.json({ sessions }) + }) + + app.post('/importable-sessions/codex/:externalSessionId/import', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.importExternalCodexSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + app.post('/importable-sessions/codex/:externalSessionId/refresh', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.refreshExternalCodexSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + app.post('/importable-sessions/claude/:externalSessionId/import', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.importExternalClaudeSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + app.post('/importable-sessions/claude/:externalSessionId/refresh', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const namespace = c.get('namespace') + const externalSessionId = c.req.param('externalSessionId') + const result = await engine.refreshExternalClaudeSession(externalSessionId, namespace) + if (result.type === 'error') { + return c.json({ error: result.message, code: result.code }, mapActionErrorStatus(result.code) as never) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + + return app +} diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 9c9237976f..2a4eabb02f 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -10,6 +10,7 @@ const spawnBodySchema = z.object({ model: z.string().optional(), effort: z.string().optional(), modelReasoningEffort: z.string().optional(), + serviceTier: z.string().optional(), yolo: z.boolean().optional(), sessionType: z.enum(['simple', 'worktree']).optional(), worktreeName: z.string().optional() @@ -61,7 +62,9 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.sessionType, parsed.data.worktreeName, undefined, - parsed.data.effort + parsed.data.effort, + undefined, + parsed.data.serviceTier ) return c.json(result) }) diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index 2400853e0b..b1e5c06564 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -31,6 +31,7 @@ function createSession(overrides?: Partial): Session { model: 'gpt-5.4', modelReasoningEffort: null, effort: null, + serviceTier: null, permissionMode: 'default', collaborationMode: 'default' } @@ -195,6 +196,61 @@ describe('sessions routes', () => { ]) }) + it('applies service tier changes for remote Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'fast' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { serviceTier: 'fast' }] + ]) + }) + + it('rejects service tier changes for local Codex sessions', async () => { + const session = createSession({ + agentState: { + controlledByUser: true, + requests: {}, + completedRequests: {} + } + }) + const { app, applySessionConfigCalls } = createApp(session) + + const response = await app.request('/api/sessions/session-1/service-tier', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ serviceTier: 'fast' }) + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Service tier can only be changed for remote Codex sessions' + }) + expect(applySessionConfigCalls).toEqual([]) + }) + + it('applies model changes for remote Codex sessions', async () => { + const { app, applySessionConfigCalls } = createApp(createSession()) + + const response = await app.request('/api/sessions/session-1/model', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: 'gpt-5.4-mini' }) + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ ok: true }) + expect(applySessionConfigCalls).toEqual([ + ['session-1', { model: 'gpt-5.4-mini' }] + ]) + }) + it('rejects effort changes for non-Claude sessions', async () => { const { app, applySessionConfigCalls } = createApp(createSession()) diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 803fadff2a..3ae090907c 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -22,6 +22,10 @@ const modelReasoningEffortSchema = z.object({ modelReasoningEffort: z.string().trim().min(1).nullable() }) +const serviceTierSchema = z.object({ + serviceTier: z.string().trim().min(1).nullable() +}) + const effortSchema = z.object({ effort: z.string().trim().min(1).nullable() }) @@ -320,8 +324,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } const flavor = sessionResult.session.metadata?.flavor ?? 'claude' - if (flavor !== 'claude' && flavor !== 'gemini') { - return c.json({ error: 'Model selection is only supported for Claude and Gemini sessions' }, 400) + if (flavor !== 'claude' && flavor !== 'gemini' && flavor !== 'codex') { + return c.json({ error: 'Model selection is only supported for Claude, Gemini, and Codex sessions' }, 400) } try { @@ -369,6 +373,42 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho } }) + app.post('/sessions/:id/service-tier', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine, { requireActive: true }) + if (sessionResult instanceof Response) { + return sessionResult + } + + const flavor = sessionResult.session.metadata?.flavor ?? 'claude' + if (flavor !== 'codex') { + return c.json({ error: 'Service tier is only supported for Codex sessions' }, 400) + } + if (sessionResult.session.agentState?.controlledByUser === true) { + return c.json({ error: 'Service tier can only be changed for remote Codex sessions' }, 409) + } + + const body = await c.req.json().catch(() => null) + const parsed = serviceTierSchema.safeParse(body) + if (!parsed.success) { + return c.json({ error: 'Invalid body' }, 400) + } + + try { + await engine.applySessionConfig(sessionResult.sessionId, { + serviceTier: parsed.data.serviceTier + }) + return c.json({ ok: true }) + } catch (error) { + const message = error instanceof Error ? error.message : 'Failed to apply service tier' + return c.json({ error: message }, 409) + } + }) + app.post('/sessions/:id/effort', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index b4dbf4eb5e..1175832f36 100644 --- a/hub/src/web/server.ts +++ b/hub/src/web/server.ts @@ -12,6 +12,7 @@ import { createAuthRoutes } from './routes/auth' import { createBindRoutes } from './routes/bind' import { createEventsRoutes } from './routes/events' import { createSessionsRoutes } from './routes/sessions' +import { createImportableSessionsRoutes } from './routes/importableSessions' import { createMessagesRoutes } from './routes/messages' import { createPermissionsRoutes } from './routes/permissions' import { createMachinesRoutes } from './routes/machines' @@ -91,6 +92,7 @@ function createWebApp(options: { app.use('/api/*', createAuthMiddleware(options.jwtSecret)) app.route('/api', createEventsRoutes(options.getSseManager, options.getSyncEngine, options.getVisibilityTracker)) app.route('/api', createSessionsRoutes(options.getSyncEngine)) + app.route('/api', createImportableSessionsRoutes(options.getSyncEngine)) app.route('/api', createMessagesRoutes(options.getSyncEngine)) app.route('/api', createPermissionsRoutes(options.getSyncEngine)) app.route('/api', createMachinesRoutes(options.getSyncEngine)) diff --git a/package.json b/package.json index 8cc9e91a70..4c497fc48a 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "scripts": { "build:site": "bun run --cwd website build && bun run --cwd docs docs:build && cp -r docs/.vitepress/dist website/dist/public/docs", "dev": "concurrently \"bun run dev:hub\" \"bun run dev:web\" --kill-others-on-exit", - "build": "bun run build:cli && bun run build:hub && bun run build:web", + "build": "bun run build:cli && bun run build:web && bun run build:hub", "build:cli": "cd cli && bun run build", "build:single-exe": "bun run download:tunwg && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone)", "build:single-exe:all": "bun run download:tunwg && bun run build:web && (cd hub && bun run generate:embedded-web-assets) && (cd cli && bun run build:exe:allinone:all)", diff --git a/shared/package.json b/shared/package.json index ca7ba82044..835bd2100a 100644 --- a/shared/package.json +++ b/shared/package.json @@ -9,6 +9,7 @@ ".": "./src/index.ts", "./messages": "./src/messages.ts", "./modes": "./src/modes.ts", + "./rpcTypes": "./src/rpcTypes.ts", "./schemas": "./src/schemas.ts", "./types": "./src/types.ts", "./voice": "./src/voice.ts" diff --git a/shared/src/flavors.test.ts b/shared/src/flavors.test.ts index a8efe5e99a..60d27e9b4e 100644 --- a/shared/src/flavors.test.ts +++ b/shared/src/flavors.test.ts @@ -22,8 +22,8 @@ describe('hasCapability', () => { expect(hasCapability('gemini', Capabilities.Effort)).toBe(false) }) - test('codex has no capabilities', () => { - expect(hasCapability('codex', Capabilities.ModelChange)).toBe(false) + test('codex supports model-change but not effort', () => { + expect(hasCapability('codex', Capabilities.ModelChange)).toBe(true) expect(hasCapability('codex', Capabilities.Effort)).toBe(false) }) @@ -86,6 +86,7 @@ describe('convenience functions', () => { test('supportsModelChange matches hasCapability', () => { expect(supportsModelChange('claude')).toBe(true) expect(supportsModelChange('gemini')).toBe(true) + expect(supportsModelChange('codex')).toBe(true) expect(supportsModelChange('cursor')).toBe(false) expect(supportsModelChange(null)).toBe(false) }) diff --git a/shared/src/flavors.ts b/shared/src/flavors.ts index 817d3dd999..ab2832921f 100644 --- a/shared/src/flavors.ts +++ b/shared/src/flavors.ts @@ -12,7 +12,7 @@ export type Capability = typeof Capabilities[keyof typeof Capabilities] const FLAVOR_CAPS: Record> = { claude: new Set([Capabilities.ModelChange, Capabilities.Effort]), gemini: new Set([Capabilities.ModelChange]), - codex: new Set([]), + codex: new Set([Capabilities.ModelChange]), cursor: new Set([]), opencode: new Set([]), } diff --git a/shared/src/index.ts b/shared/src/index.ts index f1829b1a35..748ee85a96 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -6,4 +6,5 @@ export * from './socket' export * from './sessionSummary' export * from './utils' export * from './version' +export type * from './rpcTypes' export type * from './types' diff --git a/shared/src/rpcTypes.ts b/shared/src/rpcTypes.ts new file mode 100644 index 0000000000..674a1b866b --- /dev/null +++ b/shared/src/rpcTypes.ts @@ -0,0 +1,43 @@ +import type { CodexCollaborationMode, PermissionMode } from './modes' + +export type ImportableSessionAgent = 'codex' | 'claude' + +export type ImportableCodexSessionSummary = { + agent: 'codex' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null + model?: string | null + effort?: string | null + modelReasoningEffort?: string | null + collaborationMode?: CodexCollaborationMode | null + approvalPolicy?: string | null + sandboxPolicy?: unknown | null + permissionMode?: PermissionMode | null + serviceTier?: string | null +} + +export type ImportableClaudeSessionSummary = { + agent: 'claude' + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null +} + +export type ImportableSessionSummary = + | ImportableCodexSessionSummary + | ImportableClaudeSessionSummary + +export type RpcListImportableSessionsRequest = { + agent: ImportableSessionAgent +} + +export type RpcListImportableSessionsResponse = { + sessions: ImportableSessionSummary[] +} diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index a2a7819e17..0c083b301d 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -179,6 +179,7 @@ export const SessionSchema = z.object({ model: z.string().nullable().optional().default(null), modelReasoningEffort: z.string().nullable().optional().default(null), effort: z.string().nullable().optional().default(null), + serviceTier: z.string().nullable().optional().default(null), permissionMode: PermissionModeSchema.optional(), collaborationMode: CodexCollaborationModeSchema.optional() }) diff --git a/shared/src/sessionSummary.ts b/shared/src/sessionSummary.ts index 86298208c6..b3f23c5958 100644 --- a/shared/src/sessionSummary.ts +++ b/shared/src/sessionSummary.ts @@ -1,4 +1,5 @@ import type { Session, WorktreeMetadata } from './schemas' +import type { CodexCollaborationMode, PermissionMode } from './modes' export type SessionSummaryMetadata = { name?: string @@ -20,7 +21,11 @@ export type SessionSummary = { todoProgress: { completed: number; total: number } | null pendingRequestsCount: number model: string | null + modelReasoningEffort: string | null effort: string | null + serviceTier: string | null + permissionMode: PermissionMode | null + collaborationMode: CodexCollaborationMode | null } export function toSessionSummary(session: Session): SessionSummary { @@ -56,6 +61,10 @@ export function toSessionSummary(session: Session): SessionSummary { todoProgress, pendingRequestsCount, model: session.model, - effort: session.effort + modelReasoningEffort: session.modelReasoningEffort, + effort: session.effort, + serviceTier: session.serviceTier, + permissionMode: session.permissionMode ?? null, + collaborationMode: session.collaborationMode ?? null } } diff --git a/shared/src/socket.ts b/shared/src/socket.ts index e4072f1edb..07c1fe4b72 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -142,6 +142,7 @@ export interface ClientToServerEvents { model?: string | null modelReasoningEffort?: string | null effort?: string | null + serviceTier?: string | null collaborationMode?: CodexCollaborationMode }) => void 'session-end': (data: { sid: string; time: number }) => void diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 7f1083c8af..340c85f692 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,10 +3,13 @@ import type { AuthResponse, CodexCollaborationMode, DeleteUploadResponse, + ExternalSessionActionResponse, ListDirectoryResponse, FileReadResponse, FileSearchResponse, GitCommandResponse, + ImportableSessionAgent, + ImportableSessionsResponse, MachinePathsExistsResponse, MachinesResponse, MessagesResponse, @@ -160,6 +163,25 @@ export class ApiClient { return await this.request('/api/sessions') } + async listImportableSessions(agent: ImportableSessionAgent): Promise { + const params = new URLSearchParams({ agent }) + return await this.request(`/api/importable-sessions?${params.toString()}`) + } + + async importExternalSession(agent: ImportableSessionAgent, externalSessionId: string): Promise { + return await this.request( + `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/import`, + { method: 'POST' } + ) + } + + async refreshExternalSession(agent: ImportableSessionAgent, externalSessionId: string): Promise { + return await this.request( + `/api/importable-sessions/${agent}/${encodeURIComponent(externalSessionId)}/refresh`, + { method: 'POST' } + ) + } + async getPushVapidPublicKey(): Promise { return await this.request('/api/push/vapid-public-key') } @@ -334,6 +356,13 @@ export class ApiClient { }) } + async setServiceTier(sessionId: string, serviceTier: string | null): Promise { + await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/service-tier`, { + method: 'POST', + body: JSON.stringify({ serviceTier }) + }) + } + async setEffort(sessionId: string, effort: string | null): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/effort`, { method: 'POST', @@ -399,11 +428,12 @@ export class ApiClient { yolo?: boolean, sessionType?: 'simple' | 'worktree', worktreeName?: string, - effort?: string + effort?: string, + serviceTier?: string ): Promise { return await this.request(`/api/machines/${encodeURIComponent(machineId)}/spawn`, { method: 'POST', - body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort }) + body: JSON.stringify({ directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, effort, serviceTier }) }) } diff --git a/web/src/chat/codexLifecycle.ts b/web/src/chat/codexLifecycle.ts new file mode 100644 index 0000000000..fa4089ba52 --- /dev/null +++ b/web/src/chat/codexLifecycle.ts @@ -0,0 +1,357 @@ +import type { ChatBlock, CodexAgentLifecycle, CodexAgentLifecycleStatus, ToolCallBlock } from '@/chat/types' +import { isObject } from '@hapi/protocol' +import { getInputStringAny } from '@/lib/toolInputUtils' + +const CONTROL_TOOL_NAMES = new Set(['CodexWaitAgent', 'CodexSendInput', 'CodexCloseAgent']) + +type LifecycleActionType = 'wait' | 'send' | 'close' + +function normalizeLifecycleStatus(value: string): CodexAgentLifecycleStatus | null { + const normalized = value.trim().toLowerCase() + if (normalized === 'running' || normalized === 'in_progress' || normalized === 'in progress') return 'running' + if (normalized === 'waiting' || normalized === 'pending') return 'waiting' + if (normalized === 'completed' || normalized === 'complete' || normalized === 'done' || normalized === 'finished') return 'completed' + if (normalized === 'error' || normalized === 'failed' || normalized === 'failure' || normalized === 'errored') return 'error' + if (normalized === 'closed' || normalized === 'close') return 'closed' + return null +} + +function statusPriority(status: CodexAgentLifecycleStatus): number { + switch (status) { + case 'error': + return 50 + case 'completed': + return 40 + case 'closed': + return 30 + case 'waiting': + return 20 + case 'running': + default: + return 10 + } +} + +function pickHigherStatus(current: CodexAgentLifecycleStatus, next: CodexAgentLifecycleStatus): CodexAgentLifecycleStatus { + return statusPriority(next) >= statusPriority(current) ? next : current +} + +function extractSpawnIdentity(block: ToolCallBlock): { agentId: string; nickname: string | null } | null { + const result = isObject(block.tool.result) ? block.tool.result : null + const agentId = result && typeof result.agent_id === 'string' && result.agent_id.length > 0 + ? result.agent_id + : null + if (!agentId) return null + + const nicknameFromResult = result && typeof result.nickname === 'string' && result.nickname.length > 0 + ? result.nickname + : null + const nicknameFromInput = getInputStringAny(block.tool.input, ['nickname', 'name', 'agent_name']) + + return { + agentId, + nickname: nicknameFromResult ?? nicknameFromInput + } +} + +function ensureLifecycle(block: ToolCallBlock, agentId: string, nickname: string | null): CodexAgentLifecycle { + if (block.lifecycle) { + if (nickname && !block.lifecycle.nickname) { + block.lifecycle = { ...block.lifecycle, nickname } + } + return block.lifecycle + } + + const lifecycle: CodexAgentLifecycle = { + kind: 'codex-agent-lifecycle', + agentId, + nickname: nickname ?? undefined, + status: 'running', + actions: [], + hiddenToolIds: [] + } + block.lifecycle = lifecycle + return lifecycle +} + +function stringifyTargetList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +function parseResultObject(value: unknown): Record | null { + if (isObject(value)) return value + if (typeof value !== 'string') return null + + const trimmed = value.trim() + if (!trimmed.startsWith('{')) return null + + try { + const parsed = JSON.parse(trimmed) + return isObject(parsed) ? parsed : null + } catch { + return null + } +} + +function extractResolvedWaitTargets(block: ToolCallBlock): string[] | null { + if (block.tool.name !== 'CodexWaitAgent') { + return null + } + + const result = isObject(block.tool.result) ? block.tool.result : null + if (!result || !isObject(result.statuses)) { + return null + } + + const resolvedTargets = Object.keys(result.statuses).filter((target) => target.length > 0) + return resolvedTargets.length > 0 ? resolvedTargets : null +} + +function extractControlTargets(block: ToolCallBlock): string[] { + const input = isObject(block.tool.input) ? block.tool.input : null + if (!input) return [] + + if (block.tool.name === 'CodexWaitAgent') { + return stringifyTargetList(input.targets) + } + + const target = getInputStringAny(input, ['target', 'agent_id', 'agentId']) + return target ? [target] : [] +} + +function extractWaitTargetUpdate(raw: unknown): { + status: CodexAgentLifecycleStatus | null + rawStatus: string | null + message: string | null +} { + if (typeof raw === 'string') { + return { + status: normalizeLifecycleStatus(raw), + rawStatus: raw, + message: null + } + } + + const rawObject = isObject(raw) ? raw : null + if (!rawObject) { + return { + status: null, + rawStatus: null, + message: null + } + } + + const explicitStatus = getInputStringAny(rawObject, ['status', 'state']) + const explicitMessage = getInputStringAny(rawObject, ['message', 'text', 'summary', 'output']) + if (explicitStatus) { + return { + status: normalizeLifecycleStatus(explicitStatus), + rawStatus: explicitStatus, + message: explicitMessage + } + } + + const completedMessage = getInputStringAny(rawObject, ['completed', 'complete', 'done', 'finished']) + if (completedMessage) { + return { + status: 'completed', + rawStatus: 'completed', + message: completedMessage + } + } + + const errorMessage = getInputStringAny(rawObject, ['error', 'failed', 'failure']) + if (errorMessage) { + return { + status: 'error', + rawStatus: 'error', + message: errorMessage + } + } + + return { + status: null, + rawStatus: null, + message: explicitMessage + } +} + +function summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: CodexAgentLifecycleStatus | null; summary: string } { + const result = block.tool.result + const resultObject = parseResultObject(result) + const targetLabel = targets.length > 0 ? targets.join(', ') : 'agent' + + if (!resultObject) { + return { status: null, summary: `${targetLabel}: ${String(result ?? '')}`.trim() } + } + + if (typeof resultObject.status === 'string') { + const status = normalizeLifecycleStatus(resultObject.status) + return { + status, + summary: typeof resultObject.text === 'string' && resultObject.text.trim().length > 0 + ? resultObject.text.trim() + : `${targetLabel}: ${resultObject.status}` + } + } + + const statuses = isObject(resultObject.statuses) + ? resultObject.statuses + : isObject(resultObject.status) + ? resultObject.status + : null + + if (statuses) { + const parts: string[] = [] + let status: CodexAgentLifecycleStatus | null = null + let singleTargetMessage: string | null = null + for (const target of targets) { + const update = extractWaitTargetUpdate(statuses[target]) + if (update.status) { + status = status ? pickHigherStatus(status, update.status) : update.status + } + if (targets.length === 1 && update.message) { + singleTargetMessage = update.message + } + if (update.message) { + parts.push(`${target}: ${update.message}`) + } else if (update.rawStatus) { + parts.push(`${target}: ${update.rawStatus}`) + } + } + if (singleTargetMessage) { + return { + status, + summary: singleTargetMessage + } + } + if (parts.length > 0) { + return { + status, + summary: parts.join(' / ') + } + } + } + + if (resultObject.timed_out === true) { + return { + status: 'waiting', + summary: `${targetLabel}: timed out` + } + } + + if (typeof resultObject.text === 'string' && resultObject.text.trim().length > 0) { + return { + status: null, + summary: resultObject.text.trim() + } + } + + const text = getInputStringAny(resultObject, ['message', 'summary', 'output', 'error']) + if (text) { + return { status: null, summary: text } + } + + return { + status: null, + summary: `${targetLabel}: updated` + } +} + +function summarizeSendResult(block: ToolCallBlock, target: string | null): string { + const result = block.tool.result + const resultText = getInputStringAny(result, ['message', 'summary', 'output', 'error', 'text']) + if (resultText) return resultText + return target ? `Sent input to ${target}` : 'Sent input' +} + +function summarizeCloseResult(block: ToolCallBlock, target: string | null): { status: CodexAgentLifecycleStatus | null; summary: string } { + const result = block.tool.result + const resultText = getInputStringAny(result, ['message', 'summary', 'output', 'error', 'text']) + const rawStatus = getInputStringAny(result, ['status']) + const status = rawStatus ? normalizeLifecycleStatus(rawStatus) : null + + return { + status: status ?? 'closed', + summary: resultText ?? (target ? `Closed ${target}` : 'Closed agent') + } +} + +function appendAction(lifecycle: CodexAgentLifecycle, action: LifecycleActionType, createdAt: number, summary: string): void { + lifecycle.actions.push({ type: action, createdAt, summary }) + lifecycle.latestText = summary +} + +function foldControlBlock(block: ToolCallBlock, spawnByAgentId: Map): boolean { + if (!CONTROL_TOOL_NAMES.has(block.tool.name)) return false + + const targets = extractResolvedWaitTargets(block) ?? extractControlTargets(block) + const matchedSpawnBlocks = targets + .map((target) => spawnByAgentId.get(target)) + .filter((spawn): spawn is ToolCallBlock => Boolean(spawn)) + + if (matchedSpawnBlocks.length === 0) return false + + const uniqueSpawns = [...new Set(matchedSpawnBlocks)] + + for (const spawn of uniqueSpawns) { + const spawnIdentity = extractSpawnIdentity(spawn) + if (!spawnIdentity) continue + + const spawnTargets = targets.filter((target) => spawnByAgentId.get(target) === spawn) + const scopedTargets = spawnTargets.length > 0 ? spawnTargets : targets + const lifecycle = ensureLifecycle(spawn, spawnIdentity.agentId, spawnIdentity.nickname) + lifecycle.hiddenToolIds.push(block.tool.id) + + if (block.tool.name === 'CodexWaitAgent') { + const result = summarizeWaitResult(block, scopedTargets) + if (result.status) { + lifecycle.status = pickHigherStatus(lifecycle.status, result.status) + } else if (lifecycle.status === 'running') { + lifecycle.status = 'waiting' + } + appendAction(lifecycle, 'wait', block.createdAt, result.summary) + continue + } + + if (block.tool.name === 'CodexSendInput') { + const target = scopedTargets[0] ?? null + appendAction(lifecycle, 'send', block.createdAt, summarizeSendResult(block, target)) + if (lifecycle.status === 'running') { + lifecycle.status = 'waiting' + } + continue + } + + if (block.tool.name === 'CodexCloseAgent') { + const target = scopedTargets[0] ?? null + const result = summarizeCloseResult(block, target) + if (result.status) { + lifecycle.status = pickHigherStatus(lifecycle.status, result.status) + } + appendAction(lifecycle, 'close', block.createdAt, result.summary) + } + } + + return true +} + +export function applyCodexLifecycleAggregation(blocks: ChatBlock[]): ChatBlock[] { + const spawnByAgentId = new Map() + + for (const block of blocks) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexSpawnAgent') continue + const identity = extractSpawnIdentity(block) + if (!identity) continue + const lifecycle = ensureLifecycle(block, identity.agentId, identity.nickname) + lifecycle.status = 'running' + spawnByAgentId.set(identity.agentId, block) + } + + return blocks.filter((block) => { + if (block.kind !== 'tool-call') return true + if (block.tool.name === 'CodexSpawnAgent') return true + return !foldControlBlock(block, spawnByAgentId) + }) +} diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index 3fc94709c3..8707d4090e 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -13,6 +13,62 @@ function makeMessage(content: unknown): DecryptedMessage { } describe('normalizeDecryptedMessage', () => { + it('maps Codex parentToolCallId to sidechainKey on sidechain agent payloads', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'CodexBash', + input: { command: 'pwd' }, + isSidechain: true, + parentToolCallId: 'spawn-1' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'spawn-1', + content: [ + { + type: 'tool-call', + id: 'tool-call-1' + } + ] + }) + }) + + it('preserves user sidechain metadata from record meta', () => { + const message = makeMessage({ + role: 'user', + content: { + type: 'text', + text: 'child transcript prompt' + }, + meta: { + isSidechain: true, + sidechainKey: 'spawn-1' + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'user', + isSidechain: true, + sidechainKey: 'spawn-1', + content: { + type: 'text', + text: 'child transcript prompt' + } + }) + }) + it('drops unsupported Claude system output records', () => { const message = makeMessage({ role: 'agent', diff --git a/web/src/chat/normalizeAgent.ts b/web/src/chat/normalizeAgent.ts index cd9bcdb9d1..e5e4e98c46 100644 --- a/web/src/chat/normalizeAgent.ts +++ b/web/src/chat/normalizeAgent.ts @@ -27,6 +27,47 @@ function normalizeToolResultPermissions(value: unknown): ToolResultPermission | } } +function extractSubagentSidechainKey(meta: unknown): string | null { + if (!isObject(meta)) return null + + const subagent = meta.subagent + if (Array.isArray(subagent)) { + for (const item of subagent) { + if (item && typeof item === 'object' && typeof (item as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (item as { sidechainKey: string }).sidechainKey + if (sidechainKey.length > 0) return sidechainKey + } + } + return null + } + + if (subagent && typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (subagent as { sidechainKey: string }).sidechainKey + return sidechainKey.length > 0 ? sidechainKey : null + } + + return null +} + +function resolveSidechainMetadata( + data: Record, + meta: unknown +): { isSidechain: boolean; sidechainKey?: string } { + const subagentSidechainKey = extractSubagentSidechainKey(meta) + if (subagentSidechainKey) { + return { + isSidechain: true, + sidechainKey: subagentSidechainKey + } + } + + const codexSidechainKey = Boolean(data.isSidechain) ? asString(data.parentToolCallId) ?? undefined : undefined + return { + isSidechain: Boolean(data.isSidechain), + ...(codexSidechainKey ? { sidechainKey: codexSidechainKey } : {}) + } +} + function normalizeAgentEvent(value: unknown): AgentEvent | null { if (!isObject(value) || typeof value.type !== 'string') return null return value as AgentEvent @@ -41,7 +82,7 @@ function normalizeAssistantOutput( ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) const message = isObject(data.message) ? data.message : null if (!message) return null @@ -81,6 +122,7 @@ function normalizeAssistantOutput( createdAt, role: 'agent', isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: blocks, meta, usage: inputTokens !== null && outputTokens !== null ? { @@ -102,7 +144,7 @@ function normalizeUserOutput( ): NormalizedMessage | null { const uuid = asString(data.uuid) ?? messageId const parentUUID = asString(data.parentUuid) ?? null - const isSidechain = Boolean(data.isSidechain) + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) const message = isObject(data.message) ? data.message : null if (!message) return null @@ -116,6 +158,8 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + ...(sidechainKey ? { sidechainKey } : {}), + meta, content: [{ type: 'sidechain', uuid, parentUUID, prompt: messageContent }] } } @@ -154,6 +198,8 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + ...(sidechainKey ? { sidechainKey } : {}), + meta, content: [{ type: 'sidechain', uuid, parentUUID, prompt: textParts.join('\n\n') }] } } @@ -215,6 +261,7 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: blocks, meta } @@ -350,6 +397,7 @@ export function normalizeAgentRecord( if (content.type === AGENT_MESSAGE_PAYLOAD_TYPE) { const data = isObject(content.data) ? content.data : null if (!data || typeof data.type !== 'string') return null + const { isSidechain, sidechainKey } = resolveSidechainMetadata(data, meta) if (data.type === 'message' && typeof data.message === 'string') { return { @@ -357,7 +405,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -369,7 +418,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -382,7 +432,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'tool-call', id: data.callId, @@ -403,7 +454,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'tool-result', tool_use_id: data.callId, diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts index 3785c8f610..15a87815ff 100644 --- a/web/src/chat/normalizeUser.ts +++ b/web/src/chat/normalizeUser.ts @@ -2,6 +2,15 @@ import type { NormalizedMessage } from '@/chat/types' import type { AttachmentMetadata } from '@/types/api' import { isObject } from '@hapi/protocol' +function normalizeSidechainMeta(meta: unknown): Pick | null { + if (!isObject(meta)) return null + if (meta.isSidechain !== true || typeof meta.sidechainKey !== 'string') return null + return { + isSidechain: true, + sidechainKey: meta.sidechainKey + } +} + function parseAttachments(raw: unknown): AttachmentMetadata[] | undefined { if (!Array.isArray(raw)) return undefined const attachments: AttachmentMetadata[] = [] @@ -35,26 +44,30 @@ export function normalizeUserRecord( meta?: unknown ): NormalizedMessage | null { if (typeof content === 'string') { + const sidechain = normalizeSidechainMeta(meta) return { id: messageId, localId, createdAt, role: 'user', content: { type: 'text', text: content }, - isSidechain: false, + isSidechain: sidechain?.isSidechain ?? false, + ...(sidechain ? { sidechainKey: sidechain.sidechainKey } : {}), meta } } if (isObject(content) && content.type === 'text' && typeof content.text === 'string') { const attachments = parseAttachments(content.attachments) + const sidechain = normalizeSidechainMeta(meta) return { id: messageId, localId, createdAt, role: 'user', content: { type: 'text', text: content.text, attachments }, - isSidechain: false, + isSidechain: sidechain?.isSidechain ?? false, + ...(sidechain ? { sidechainKey: sidechain.sidechainKey } : {}), meta } } diff --git a/web/src/chat/reconcile.test.ts b/web/src/chat/reconcile.test.ts new file mode 100644 index 0000000000..bf28f586b4 --- /dev/null +++ b/web/src/chat/reconcile.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from 'vitest' +import { reconcileChatBlocks, type ChatBlocksById } from './reconcile' +import type { CodexAgentLifecycleStatus, ToolCallBlock } from './types' + +const sharedInput = { message: 'child prompt' } +const sharedResult = { agent_id: 'agent-1', nickname: 'First' } + +function spawnBlock(status: CodexAgentLifecycleStatus): ToolCallBlock { + return { + kind: 'tool-call', + id: 'spawn-1', + localId: null, + createdAt: 1, + tool: { + id: 'spawn-1', + name: 'CodexSpawnAgent', + state: 'completed', + input: sharedInput, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: sharedResult + }, + children: [], + lifecycle: { + kind: 'codex-agent-lifecycle', + agentId: 'agent-1', + nickname: 'First', + status, + latestText: status === 'completed' ? 'child done' : 'agent-1:', + actions: [{ + type: 'wait', + createdAt: 3, + summary: status === 'completed' ? 'child done' : 'agent-1:' + }], + hiddenToolIds: ['wait-1'] + } + } +} + +describe('reconcileChatBlocks', () => { + it('does not reuse stale tool blocks when Codex lifecycle changes', () => { + const prev = spawnBlock('waiting') + const next = spawnBlock('completed') + const prevById: ChatBlocksById = new Map([[prev.id, prev]]) + + const reconciled = reconcileChatBlocks([next], prevById) + const block = reconciled.blocks[0] + + expect(block).toBe(next) + expect(block.kind === 'tool-call' ? block.lifecycle?.status : null).toBe('completed') + expect(block.kind === 'tool-call' ? block.lifecycle?.latestText : null).toBe('child done') + }) +}) diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 12517a2e4c..2941dfa14a 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -5,6 +5,8 @@ import type { AgentTextBlock, ChatBlock, CliOutputBlock, + CodexAgentLifecycle, + CodexAgentLifecycleAction, ToolCallBlock, ToolPermission, UserTextBlock, @@ -76,6 +78,33 @@ function arePermissionsEqual(left?: ToolPermission, right?: ToolPermission): boo && areAnswersEqual(left.answers, right.answers) } +function areLifecycleActionsEqual(left: CodexAgentLifecycleAction[], right: CodexAgentLifecycleAction[]): boolean { + if (left === right) return true + if (left.length !== right.length) return false + for (let i = 0; i < left.length; i += 1) { + if ( + left[i].type !== right[i].type + || left[i].createdAt !== right[i].createdAt + || left[i].summary !== right[i].summary + ) { + return false + } + } + return true +} + +function areCodexLifecyclesEqual(left?: CodexAgentLifecycle, right?: CodexAgentLifecycle): boolean { + if (left === right) return true + if (!left || !right) return false + return left.kind === right.kind + && left.agentId === right.agentId + && left.nickname === right.nickname + && left.status === right.status + && left.latestText === right.latestText + && areStringArraysEqual(left.hiddenToolIds, right.hiddenToolIds) + && areLifecycleActionsEqual(left.actions, right.actions) +} + function getEventKey(event: AgentEvent): string { switch (event.type) { case 'switch': @@ -156,6 +185,7 @@ function areToolCallsEqual(left: ToolCallBlock, right: ToolCallBlock, childrenSa && left.tool.startedAt === right.tool.startedAt && left.tool.completedAt === right.tool.completedAt && arePermissionsEqual(left.tool.permission, right.tool.permission) + && areCodexLifecyclesEqual(left.lifecycle, right.lifecycle) } function reconcileBlockList(blocks: ChatBlock[], prevById: ChatBlocksById): ChatBlock[] { diff --git a/web/src/chat/reducer.test.ts b/web/src/chat/reducer.test.ts new file mode 100644 index 0000000000..08bea09f36 --- /dev/null +++ b/web/src/chat/reducer.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest' +import { reduceChatBlocks } from './reducer' +import type { NormalizedAgentContent, NormalizedMessage, ToolCallBlock } from './types' + +function agentToolCall( + messageId: string, + toolUseId: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id: toolUseId, + name, + input, + description: null, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function agentToolResult( + messageId: string, + toolUseId: string, + content: unknown, + createdAt: number +): NormalizedMessage { + return { + id: messageId, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: toolUseId, + content, + is_error: false, + uuid: `${messageId}-uuid`, + parentUUID: null + }] + } +} + +function userText( + id: string, + text: string, + createdAt: number, + extra: Partial> = {} +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'user', + isSidechain: extra.isSidechain ?? false, + ...(extra.sidechainKey ? { sidechainKey: extra.sidechainKey } : {}), + content: { type: 'text', text } + } +} + +function agentText( + id: string, + text: string, + createdAt: number, + extra: Partial> = {} +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: extra.isSidechain ?? false, + ...(extra.sidechainKey ? { sidechainKey: extra.sidechainKey } : {}), + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +function agentMessage( + id: string, + createdAt: number, + content: NormalizedAgentContent[] +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content + } +} + +describe('reduceChatBlocks subagent grouping', () => { + it('groups multiple Codex sidechain transcripts under their matching spawn cards', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + userText('child-1-user', 'First child prompt', 5, { isSidechain: true, sidechainKey: 'spawn-1' }), + agentText('child-1-agent', 'First child answer', 6, { isSidechain: true, sidechainKey: 'spawn-1' }), + userText('child-2-user', 'Second child prompt', 7, { isSidechain: true, sidechainKey: 'spawn-2' }), + agentText('child-2-agent', 'Second child answer', 8, { isSidechain: true, sidechainKey: 'spawn-2' }) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlocks).toHaveLength(2) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'First child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'First child answer' }) + ]) + ) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'Second child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'Second child answer' }) + ]) + ) + expect(reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'First child answer')).toBe(false) + expect(reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'Second child answer')).toBe(false) + }) + + it('folds multi-target Codex wait results into each matching spawn card', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + agentToolCall('wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 5), + agentToolResult( + 'wait-result', + 'wait-1', + JSON.stringify({ + status: { + 'agent-1': { completed: 'First child done' }, + 'agent-2': { completed: 'Second child done' } + }, + timed_out: false + }), + 6 + ) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlocks).toHaveLength(2) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'First child done' + }) + ) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'Second child done' + }) + ) + expect(reduced.blocks.some((block) => block.kind === 'tool-call' && block.tool.name === 'CodexWaitAgent')).toBe(false) + }) + + it('keeps later Codex wait results on the root timeline after spawn nickname backfills', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + agentToolCall('wait-both-call', 'wait-both', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 5), + agentToolResult('spawn-1-backfill', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 6), + agentToolResult('spawn-2-backfill', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 7), + agentToolResult('wait-both-result', 'wait-both', { statuses: { 'agent-1': { status: 'completed', message: 'First child done' } } }, 8), + agentToolCall('wait-second-call', 'wait-second', 'CodexWaitAgent', { targets: ['agent-2'] }, 9), + agentToolResult('wait-second-result', 'wait-second', { statuses: { 'agent-2': { status: 'completed', message: 'Second child done' } } }, 10) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'First child done' + }) + ) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'Second child done' + }) + ) + }) + + it('keeps each Codex spawn completed when multi-agent wait results arrive in separate chunks', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('spawn-1-result', 'spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('spawn-2-result', 'spawn-2', { agent_id: 'agent-2' }, 4), + agentToolCall('wait-both-call', 'wait-both', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 5), + userText('child-2-user', 'Second child prompt', 6, { isSidechain: true, sidechainKey: 'spawn-2' }), + userText('child-1-user', 'First child prompt', 7, { isSidechain: true, sidechainKey: 'spawn-1' }), + agentToolResult('spawn-2-backfill', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 8), + agentToolResult('spawn-1-backfill', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 9), + agentText('child-2-agent', 'Second child done', 10, { isSidechain: true, sidechainKey: 'spawn-2' }), + agentToolResult('wait-both-result', 'wait-both', { statuses: { 'agent-2': { status: 'completed', message: 'Second child done' } } }, 11), + agentText('child-1-stray', 'First child done', 12), + agentToolCall('wait-first-call', 'wait-first', 'CodexWaitAgent', { targets: ['agent-1'] }, 13), + agentToolResult('wait-first-result', 'wait-first', { statuses: { 'agent-1': { status: 'completed', message: 'First child done' } } }, 14), + agentText('root-final', 'SUBAGENT_UI_OK', 15) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-1')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'First child done' + }) + ) + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle).toEqual( + expect.objectContaining({ + status: 'completed', + latestText: 'Second child done' + }) + ) + expect(reduced.blocks.some((block) => block.kind === 'tool-call' && block.tool.name === 'CodexWaitAgent')).toBe(false) + }) + + it('suppresses Codex title tool calls from visible chat blocks', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('title-call', 'title-1', 'change_title', { title: 'Better Session Title' }, 1), + agentToolResult('title-result', 'title-1', { ok: true }, 2) + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks).toEqual([]) + }) + + it('groups Claude sidechain messages by the Task tool call id, not the parent message id', () => { + const messages: NormalizedMessage[] = [ + agentMessage('msg-parent', 1, [{ + type: 'tool-call', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate flaky test' }, + description: null, + uuid: 'parent-uuid', + parentUUID: null + }]), + { + id: 'child-root', + localId: null, + createdAt: 2, + role: 'agent', + isSidechain: true, + content: [{ + type: 'sidechain', + uuid: 'child-root-uuid', + parentUUID: null, + prompt: 'Investigate flaky test' + }] + }, + agentText('child-agent', 'child answer', 3, { isSidechain: true, sidechainKey: 'task-1' }) + ] + + const reduced = reduceChatBlocks(messages, null) + const taskBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'task-1' + ) + + expect(taskBlock?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + }) +}) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 798499c673..efabbea184 100644 --- a/web/src/chat/reducer.ts +++ b/web/src/chat/reducer.ts @@ -1,15 +1,130 @@ import type { AgentState } from '@/types/api' -import type { ChatBlock, NormalizedMessage, UsageData } from '@/chat/types' +import type { ChatBlock, NormalizedMessage, ToolCallBlock, UsageData } from '@/chat/types' +import { applyCodexLifecycleAggregation } from '@/chat/codexLifecycle' +import { annotateSubagentSidechains } from '@/chat/subagentSidechain' import { traceMessages, type TracedMessage } from '@/chat/tracer' import { dedupeAgentEvents, foldApiErrorEvents } from '@/chat/reducerEvents' import { collectTitleChanges, collectToolIdsFromMessages, ensureToolBlock, getPermissions } from '@/chat/reducerTools' import { reduceTimeline } from '@/chat/reducerTimeline' +import { isObject } from '@hapi/protocol' // Calculate context size from usage data function calculateContextSize(usage: UsageData): number { return (usage.cache_creation_input_tokens || 0) + (usage.cache_read_input_tokens || 0) + usage.input_tokens } +function groupMessagesBySidechain(messages: TracedMessage[]): { groups: Map; root: TracedMessage[] } { + const groups = new Map() + const root: TracedMessage[] = [] + + for (const msg of messages) { + const groupId = msg.sidechainId ?? msg.sidechainKey + if (groupId) { + const existing = groups.get(groupId) ?? [] + existing.push(msg) + groups.set(groupId, existing) + continue + } + + root.push(msg) + } + + return { groups, root } +} + +function attachCodexSpawnChildren( + blocks: ChatBlock[], + groups: Map, + consumedGroupIds: Set, + reduceGroup: (groupId: string) => ChatBlock[] +): void { + for (const block of blocks) { + if (block.kind !== 'tool-call') continue + + if (block.tool.name === 'CodexSpawnAgent' && groups.has(block.tool.id) && !consumedGroupIds.has(block.tool.id)) { + consumedGroupIds.add(block.tool.id) + block.children = reduceGroup(block.tool.id) + } + + if (block.children.length > 0) { + attachCodexSpawnChildren(block.children, groups, consumedGroupIds, reduceGroup) + } + } +} + +function appendUnconsumedSidechainGroups( + blocks: ChatBlock[], + groups: Map, + consumedGroupIds: Set, + reduceGroup: (groupId: string) => ChatBlock[] +): void { + const preservedBlocks: ChatBlock[] = [] + for (const [groupId, sidechain] of groups) { + if (consumedGroupIds.has(groupId) || sidechain.length === 0) { + continue + } + + preservedBlocks.push(...reduceGroup(groupId)) + } + + if (preservedBlocks.length === 0) return + + const merged = [...blocks, ...preservedBlocks].sort((a, b) => { + if (a.createdAt !== b.createdAt) return a.createdAt - b.createdAt + return 0 + }) + + blocks.splice(0, blocks.length, ...merged) +} + +function extractSpawnAgentId(block: ToolCallBlock): string | null { + const result = isObject(block.tool.result) ? block.tool.result : null + return result && typeof result.agent_id === 'string' && result.agent_id.length > 0 + ? result.agent_id + : null +} + +function reattachWaitBackfilledChildReplies(blocks: ChatBlock[]): void { + const spawnByAgentId = new Map() + + for (const block of blocks) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexSpawnAgent') continue + const agentId = extractSpawnAgentId(block) + if (agentId) { + spawnByAgentId.set(agentId, block) + } + } + + for (const block of [...blocks]) { + if (block.kind !== 'tool-call' || block.tool.name !== 'CodexWaitAgent') continue + const result = isObject(block.tool.result) ? block.tool.result : null + const statuses = result && isObject(result.statuses) ? result.statuses : null + if (!statuses) continue + + for (const [agentId, rawState] of Object.entries(statuses)) { + const spawn = spawnByAgentId.get(agentId) + const state = isObject(rawState) ? rawState : null + const message = state && typeof state.message === 'string' && state.message.trim().length > 0 + ? state.message.trim() + : null + if (!spawn || !message) continue + + const alreadyNested = spawn.children.some( + (child) => child.kind === 'agent-text' && child.text.trim() === message + ) + if (alreadyNested) continue + + const strayIndex = blocks.findIndex( + (candidate) => candidate.kind === 'agent-text' && candidate.text.trim() === message + ) + if (strayIndex === -1) continue + + const [stray] = blocks.splice(strayIndex, 1) + spawn.children.push(stray) + } + } +} + export type LatestUsage = { inputTokens: number outputTokens: number @@ -28,18 +143,8 @@ export function reduceChatBlocks( const titleChangesByToolUseId = collectTitleChanges(normalized) const traced = traceMessages(normalized) - const groups = new Map() - const root: TracedMessage[] = [] - - for (const msg of traced) { - if (msg.sidechainId) { - const existing = groups.get(msg.sidechainId) ?? [] - existing.push(msg) - groups.set(msg.sidechainId, existing) - } else { - root.push(msg) - } - } + const annotated = annotateSubagentSidechains(traced) + const { groups, root } = groupMessagesBySidechain(annotated) const consumedGroupIds = new Set() const emittedTitleChangeToolUseIds = new Set() @@ -47,6 +152,17 @@ export function reduceChatBlocks( const rootResult = reduceTimeline(root, reducerContext) let hasReadyEvent = rootResult.hasReadyEvent + const reduceGroup = (groupId: string): ChatBlock[] => { + const sidechain = groups.get(groupId) ?? [] + const child = reduceTimeline(sidechain, reducerContext, { renderSidechainPromptAsUserText: true }) + hasReadyEvent = hasReadyEvent || child.hasReadyEvent + return child.blocks + } + + attachCodexSpawnChildren(rootResult.blocks, groups, consumedGroupIds, reduceGroup) + reattachWaitBackfilledChildReplies(rootResult.blocks) + appendUnconsumedSidechainGroups(rootResult.blocks, groups, consumedGroupIds, reduceGroup) + // Only create permission-only tool cards when there is no tool call/result in the transcript. // Also skip if the permission is older than the oldest message in the current view, // to avoid mixing old tool cards with newer messages when paginating. @@ -107,5 +223,7 @@ export function reduceChatBlocks( } } - return { blocks: dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks)), hasReadyEvent, latestUsage } + const mergedBlocks = applyCodexLifecycleAggregation(dedupeAgentEvents(foldApiErrorEvents(rootResult.blocks))) + + return { blocks: mergedBlocks, hasReadyEvent, latestUsage } } diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index e434af9766..342e7b0a23 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -2,7 +2,7 @@ import type { ChatBlock, ToolCallBlock, ToolPermission } from '@/chat/types' import type { TracedMessage } from '@/chat/tracer' import { createCliOutputBlock, isCliOutputText, mergeCliOutputBlocks } from '@/chat/reducerCliOutput' import { parseMessageAsEvent } from '@/chat/reducerEvents' -import { ensureToolBlock, extractTitleFromChangeTitleInput, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools' +import { ensureToolBlock, isChangeTitleToolName, type PermissionEntry } from '@/chat/reducerTools' export function reduceTimeline( messages: TracedMessage[], @@ -12,6 +12,9 @@ export function reduceTimeline( consumedGroupIds: Set titleChangesByToolUseId: Map emittedTitleChangeToolUseIds: Set + }, + options?: { + renderSidechainPromptAsUserText?: boolean } ): { blocks: ChatBlock[]; toolBlocksById: Map; hasReadyEvent: boolean } { const blocks: ChatBlock[] = [] @@ -175,17 +178,6 @@ export function reduceTimeline( if (c.type === 'tool-call') { if (isChangeTitleToolName(c.name)) { - const title = context.titleChangesByToolUseId.get(c.id) ?? extractTitleFromChangeTitleInput(c.input) - if (title && !context.emittedTitleChangeToolUseIds.has(c.id)) { - context.emittedTitleChangeToolUseIds.add(c.id) - blocks.push({ - kind: 'agent-event', - id: `${msg.id}:${idx}`, - createdAt: msg.createdAt, - event: { type: 'title-changed', title }, - meta: msg.meta - }) - } continue } @@ -206,10 +198,10 @@ export function reduceTimeline( block.tool.startedAt = msg.createdAt } - if (c.name === 'Task' && !context.consumedGroupIds.has(msg.id)) { - const sidechain = context.groups.get(msg.id) ?? null + if (!context.consumedGroupIds.has(c.id)) { + const sidechain = context.groups.get(c.id) ?? null if (sidechain && sidechain.length > 0) { - context.consumedGroupIds.add(msg.id) + context.consumedGroupIds.add(c.id) const child = reduceTimeline(sidechain, context) hasReadyEvent = hasReadyEvent || child.hasReadyEvent block.children = child.blocks @@ -221,16 +213,6 @@ export function reduceTimeline( if (c.type === 'tool-result') { const title = context.titleChangesByToolUseId.get(c.tool_use_id) ?? null if (title) { - if (!context.emittedTitleChangeToolUseIds.has(c.tool_use_id)) { - context.emittedTitleChangeToolUseIds.add(c.tool_use_id) - blocks.push({ - kind: 'agent-event', - id: `${msg.id}:${idx}`, - createdAt: msg.createdAt, - event: { type: 'title-changed', title }, - meta: msg.meta - }) - } continue } @@ -286,6 +268,17 @@ export function reduceTimeline( meta: msg.meta }) } + continue + } + if (options?.renderSidechainPromptAsUserText) { + blocks.push({ + kind: 'user-text', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.prompt, + meta: msg.meta + }) } // Skip rendering prompt text (already in parent Task tool card or not user-visible) continue diff --git a/web/src/chat/reducerTools.ts b/web/src/chat/reducerTools.ts index 7031a5ac33..a3127abe9a 100644 --- a/web/src/chat/reducerTools.ts +++ b/web/src/chat/reducerTools.ts @@ -143,7 +143,11 @@ export function collectToolIdsFromMessages(messages: NormalizedMessage[]): Set 0) return sidechainKey + } + } + return null + } + + if (subagent && typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + const sidechainKey = (subagent as { sidechainKey: string }).sidechainKey + return sidechainKey.length > 0 ? sidechainKey : null + } + + return null +} + +function getToolCallBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-call') +} + +function getToolResultBlocks(message: NormalizedMessage): Extract[] { + if (message.role !== 'agent') return [] + return message.content.filter((content): content is Extract => content.type === 'tool-result') +} + +function extractSpawnAgentId( + message: NormalizedMessage, + toolNameByToolUseId: Map +): { agentId: string; spawnToolUseId: string } | null { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (!agentId || agentId.length === 0) continue + + return { agentId, spawnToolUseId: result.tool_use_id } + } + + return null +} + +function extractWaitTargets(message: NormalizedMessage): string[] { + for (const toolCall of getToolCallBlocks(message)) { + if (toolCall.name !== 'CodexWaitAgent') continue + if (!isObject(toolCall.input) || !Array.isArray(toolCall.input.targets)) continue + + return toolCall.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + } + + return [] +} + +function messageContainsCodexControlToolResult( + message: NormalizedMessage, + toolNameByToolUseId: Map +): boolean { + return getToolResultBlocks(message).some((toolResult) => { + const toolName = toolNameByToolUseId.get(toolResult.tool_use_id) + return typeof toolName === 'string' && CODEX_CONTROL_TOOL_NAMES.has(toolName) + }) +} + +function messageLooksLikeInlineChildConversation(message: NormalizedMessage): boolean { + if (message.role === 'user') { + return message.content.type === 'text' && !message.content.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX) + } + + if (message.role !== 'agent') return false + if (message.content.length === 0) return false + + let sawNestableContent = false + for (const block of message.content) { + if (block.type === 'summary' || block.type === 'sidechain') return false + if (block.type === 'text') { + if (block.text.trimStart().startsWith(SUBAGENT_NOTIFICATION_PREFIX)) return false + sawNestableContent = true + continue + } + if (block.type === 'reasoning' || block.type === 'tool-call' || block.type === 'tool-result') { + sawNestableContent = true + continue + } + return false + } + + return sawNestableContent +} + +function messageContainsSpawnToolCall(message: NormalizedMessage): boolean { + return getToolCallBlocks(message).some((toolCall) => toolCall.name === 'CodexSpawnAgent') +} + +function removeActiveAgents(activeAgentIds: string[], targets: string[]): string[] { + if (targets.length === 0) return activeAgentIds + const closed = new Set(targets) + return activeAgentIds.filter((agentId) => !closed.has(agentId)) +} + +function annotateExplicitSidechain(message: NormalizedMessage): NormalizedMessage | null { + const sidechainKey = message.sidechainKey ?? extractSubagentSidechainKey(message.meta) + if (!sidechainKey) return null + return { + ...message, + isSidechain: true, + sidechainKey + } +} + +export function annotateSubagentSidechains(messages: T[]): T[] { + const toolNameByToolUseId = new Map() + for (const message of messages) { + for (const toolCall of getToolCallBlocks(message)) { + toolNameByToolUseId.set(toolCall.id, toolCall.name) + } + } + + const validSpawnToolUseIds = new Set() + for (const message of messages) { + for (const result of getToolResultBlocks(message)) { + const toolName = toolNameByToolUseId.get(result.tool_use_id) + if (toolName !== 'CodexSpawnAgent') continue + if (!isObject(result.content)) continue + const agentId = typeof result.content.agent_id === 'string' ? result.content.agent_id : null + if (agentId && agentId.length > 0) { + validSpawnToolUseIds.add(result.tool_use_id) + } + } + } + + const agentIdToSpawnToolUseId = new Map() + let activeAgentIds: string[] = [] + let pendingSpawnToolUseId: string | null = null + + const result: T[] = [] + + for (const message of messages) { + const explicit = annotateExplicitSidechain(message) + if (explicit) { + result.push(explicit as T) + continue + } + + let hasCodexSpawnToolCall = false + for (const toolCall of getToolCallBlocks(message)) { + if (toolCall.name === 'CodexSpawnAgent' && validSpawnToolUseIds.has(toolCall.id)) { + pendingSpawnToolUseId = toolCall.id + hasCodexSpawnToolCall = true + } + } + + const spawn = extractSpawnAgentId(message, toolNameByToolUseId) + if (spawn) { + pendingSpawnToolUseId = null + const alreadyKnownSpawn = agentIdToSpawnToolUseId.get(spawn.agentId) === spawn.spawnToolUseId + agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) + if (!alreadyKnownSpawn) { + activeAgentIds = removeActiveAgents(activeAgentIds, [spawn.agentId]) + activeAgentIds.push(spawn.agentId) + } + result.push({ ...message }) + continue + } + + const waitTargets = extractWaitTargets(message) + if (waitTargets.length > 0) { + activeAgentIds = removeActiveAgents(activeAgentIds, waitTargets) + result.push({ ...message }) + continue + } + + if (messageContainsCodexControlToolResult(message, toolNameByToolUseId)) { + result.push({ ...message }) + continue + } + + const activeAgentId = activeAgentIds.length === 1 ? activeAgentIds[0] : null + let activeSpawnToolUseId = activeAgentId ? agentIdToSpawnToolUseId.get(activeAgentId) ?? null : null + if (!activeSpawnToolUseId && pendingSpawnToolUseId && !hasCodexSpawnToolCall) { + activeSpawnToolUseId = pendingSpawnToolUseId + } + if ( + activeSpawnToolUseId !== null + && !messageContainsSpawnToolCall(message) + && messageLooksLikeInlineChildConversation(message) + ) { + result.push({ + ...message, + isSidechain: true, + sidechainKey: activeSpawnToolUseId + } as T) + continue + } + + result.push({ ...message }) + } + + return result +} diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index db3981c814..50eac34c9f 100644 --- a/web/src/chat/tracer.ts +++ b/web/src/chat/tracer.ts @@ -6,7 +6,7 @@ export type TracedMessage = NormalizedMessage & { } type TracerState = { - promptToTaskId: Map + promptToTaskIds: Map> uuidToSidechainId: Map orphanMessages: Map } @@ -49,9 +49,42 @@ function processOrphans(state: TracerState, parentUuid: string, sidechainId: str return results } +function flushOrphansAsRoot(state: TracerState, parentUuid: string): TracedMessage[] { + const results: TracedMessage[] = [] + const orphans = state.orphanMessages.get(parentUuid) + if (!orphans) return results + state.orphanMessages.delete(parentUuid) + + for (const orphan of orphans) { + results.push({ ...orphan }) + + const uuid = getMessageUuid(orphan) + if (uuid) { + results.push(...flushOrphansAsRoot(state, uuid)) + } + } + + return results +} + +function addPromptTaskId(state: TracerState, prompt: string, taskId: string): void { + const existing = state.promptToTaskIds.get(prompt) + if (existing) { + existing.add(taskId) + return + } + state.promptToTaskIds.set(prompt, new Set([taskId])) +} + +function resolvePromptTaskId(state: TracerState, prompt: string): string | null { + const taskIds = state.promptToTaskIds.get(prompt) + if (!taskIds || taskIds.size !== 1) return null + return taskIds.values().next().value ?? null +} + export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const state: TracerState = { - promptToTaskId: new Map(), + promptToTaskIds: new Map(), uuidToSidechainId: new Map(), orphanMessages: new Map() } @@ -65,7 +98,7 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { if (content.type !== 'tool-call' || content.name !== 'Task') continue const input = content.input if (!isObject(input) || typeof input.prompt !== 'string') continue - state.promptToTaskId.set(input.prompt, message.id) + addPromptTaskId(state, input.prompt, content.id) } } @@ -78,12 +111,23 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { const uuid = getMessageUuid(message) const parentUuid = getParentUuid(message) + if (message.sidechainKey) { + if (uuid) { + state.uuidToSidechainId.set(uuid, message.sidechainKey) + } + results.push({ ...message, sidechainId: message.sidechainKey }) + if (uuid) { + results.push(...processOrphans(state, uuid, message.sidechainKey)) + } + continue + } + // Sidechain root matching (prompt == Task.prompt). let sidechainId: string | undefined if (message.role === 'agent') { for (const content of message.content) { if (content.type !== 'sidechain') continue - const taskId = state.promptToTaskId.get(content.prompt) + const taskId = resolvePromptTaskId(state, content.prompt) if (taskId) { sidechainId = taskId break @@ -119,5 +163,9 @@ export function traceMessages(messages: NormalizedMessage[]): TracedMessage[] { results.push({ ...message }) } + for (const [parentUuid] of state.orphanMessages) { + results.push(...flushOrphansAsRoot(state, parentUuid)) + } + return results } diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 7261c93323..e6d4cc12a2 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -81,6 +81,7 @@ export type NormalizedMessage = ({ localId: string | null createdAt: number isSidechain: boolean + sidechainKey?: string meta?: unknown usage?: UsageData status?: MessageStatus @@ -100,6 +101,24 @@ export type ToolPermission = { completedAt?: number | null } +export type CodexAgentLifecycleStatus = 'running' | 'waiting' | 'completed' | 'error' | 'closed' + +export type CodexAgentLifecycleAction = { + type: 'wait' | 'send' | 'close' + createdAt: number + summary: string +} + +export type CodexAgentLifecycle = { + kind: 'codex-agent-lifecycle' + agentId: string + nickname?: string + status: CodexAgentLifecycleStatus + latestText?: string + actions: CodexAgentLifecycleAction[] + hiddenToolIds: string[] +} + export type ChatToolCall = { id: string name: string @@ -168,6 +187,7 @@ export type ToolCallBlock = { createdAt: number tool: ChatToolCall children: ChatBlock[] + lifecycle?: CodexAgentLifecycle meta?: unknown } diff --git a/web/src/components/AssistantChat/HappyComposer.tsx b/web/src/components/AssistantChat/HappyComposer.tsx index 6d0e20d3d5..8c745e4b38 100644 --- a/web/src/components/AssistantChat/HappyComposer.tsx +++ b/web/src/components/AssistantChat/HappyComposer.tsx @@ -32,6 +32,7 @@ import { useTranslation } from '@/lib/use-translation' import { getModelOptionsForFlavor, getNextModelForFlavor } from './modelOptions' import { getClaudeComposerEffortOptions } from './claudeEffortOptions' import { getCodexComposerReasoningEffortOptions } from './codexReasoningEffortOptions' +import { getCodexComposerServiceTierOptions } from './codexServiceTierOptions' export interface TextInputState { text: string @@ -47,6 +48,7 @@ export function HappyComposer(props: { collaborationMode?: CodexCollaborationMode model?: string | null modelReasoningEffort?: string | null + serviceTier?: string | null effort?: string | null active?: boolean allowSendWhenInactive?: boolean @@ -60,6 +62,7 @@ export function HappyComposer(props: { onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void onModelReasoningEffortChange?: (modelReasoningEffort: string | null) => void + onServiceTierChange?: (serviceTier: string | null) => void onEffortChange?: (effort: string | null) => void onSwitchToRemote?: () => void onTerminal?: () => void @@ -80,6 +83,7 @@ export function HappyComposer(props: { collaborationMode: rawCollaborationMode, model: rawModel, modelReasoningEffort: rawModelReasoningEffort, + serviceTier: rawServiceTier, effort: rawEffort, active = true, allowSendWhenInactive = false, @@ -93,6 +97,7 @@ export function HappyComposer(props: { onPermissionModeChange, onModelChange, onModelReasoningEffortChange, + onServiceTierChange, onEffortChange, onSwitchToRemote, onTerminal, @@ -110,6 +115,8 @@ export function HappyComposer(props: { const collaborationMode = rawCollaborationMode ?? 'default' const model = rawModel ?? null const modelReasoningEffort = rawModelReasoningEffort ?? null + const serviceTier = rawServiceTier ?? null + const selectedServiceTier = serviceTier?.trim().toLowerCase() || null const effort = rawEffort ?? null const api = useAssistantApi() @@ -286,6 +293,10 @@ export function HappyComposer(props: { () => agentFlavor === 'codex' ? getCodexComposerReasoningEffortOptions(modelReasoningEffort) : [], [agentFlavor, modelReasoningEffort] ) + const codexServiceTierOptions = useMemo( + () => agentFlavor === 'codex' ? getCodexComposerServiceTierOptions(serviceTier) : [], + [agentFlavor, serviceTier] + ) const claudeEffortOptions = useMemo( () => getClaudeComposerEffortOptions(effort), [effort] @@ -468,6 +479,13 @@ export function HappyComposer(props: { haptic('light') }, [onModelReasoningEffortChange, controlsDisabled, haptic]) + const handleServiceTierChange = useCallback((nextServiceTier: string | null) => { + if (!onServiceTierChange || controlsDisabled) return + onServiceTierChange(nextServiceTier) + setShowSettings(false) + haptic('light') + }, [onServiceTierChange, controlsDisabled, haptic]) + const handleEffortChange = useCallback((nextEffort: string | null) => { if (!onEffortChange || controlsDisabled) return onEffortChange(nextEffort) @@ -479,12 +497,14 @@ export function HappyComposer(props: { const showPermissionSettings = Boolean(onPermissionModeChange && permissionModeOptions.length > 0) const showModelSettings = Boolean(onModelChange && supportsModelChange(agentFlavor)) const showModelReasoningEffortSettings = Boolean(onModelReasoningEffortChange && codexReasoningEffortOptions.length > 0) + const showServiceTierSettings = Boolean(onServiceTierChange && codexServiceTierOptions.length > 0) const showEffortSettings = Boolean(onEffortChange && supportsEffort(agentFlavor)) const showSettingsButton = Boolean( showCollaborationSettings || showPermissionSettings || showModelSettings || showModelReasoningEffortSettings + || showServiceTierSettings || showEffortSettings ) const showAbortButton = true @@ -495,7 +515,7 @@ export function HappyComposer(props: { }, [api]) const overlays = useMemo(() => { - if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings)) { + if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showServiceTierSettings || showEffortSettings)) { return (
@@ -536,7 +556,7 @@ export function HappyComposer(props: {
) : null} - {showCollaborationSettings && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? ( + {showCollaborationSettings && (showPermissionSettings || showModelSettings || showModelReasoningEffortSettings || showServiceTierSettings || showEffortSettings) ? (
) : null} @@ -577,7 +597,7 @@ export function HappyComposer(props: {
) : null} - {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelReasoningEffortSettings || showEffortSettings) ? ( + {(showCollaborationSettings || showPermissionSettings) && (showModelSettings || showModelReasoningEffortSettings || showServiceTierSettings || showEffortSettings) ? (
) : null} @@ -618,7 +638,7 @@ export function HappyComposer(props: {
) : null} - {(showModelSettings || showModelReasoningEffortSettings) && showEffortSettings ? ( + {showModelSettings && (showModelReasoningEffortSettings || showServiceTierSettings || showEffortSettings) ? (
) : null} @@ -659,7 +679,48 @@ export function HappyComposer(props: {
) : null} - {showModelReasoningEffortSettings && showEffortSettings ? ( + {showModelReasoningEffortSettings && (showServiceTierSettings || showEffortSettings) ? ( +
+ ) : null} + + {showServiceTierSettings ? ( +
+
+ {t('misc.serviceTier')} +
+ {codexServiceTierOptions.map((option) => ( + + ))} +
+ ) : null} + + {showServiceTierSettings && showEffortSettings ? (
) : null} @@ -725,9 +786,11 @@ export function HappyComposer(props: { showPermissionSettings, showModelSettings, showModelReasoningEffortSettings, + showServiceTierSettings, showEffortSettings, claudeModelOptions, codexReasoningEffortOptions, + codexServiceTierOptions, claudeEffortOptions, suggestions, selectedIndex, @@ -736,6 +799,8 @@ export function HappyComposer(props: { permissionMode, model, modelReasoningEffort, + serviceTier, + selectedServiceTier, effort, collaborationModeOptions, permissionModeOptions, @@ -743,6 +808,7 @@ export function HappyComposer(props: { handlePermissionChange, handleModelChange, handleModelReasoningEffortChange, + handleServiceTierChange, handleEffortChange, handleSuggestionSelect, t @@ -761,6 +827,8 @@ export function HappyComposer(props: { backgroundTaskCount={backgroundTaskCount} contextSize={contextSize} model={model} + modelReasoningEffort={modelReasoningEffort} + serviceTier={serviceTier} permissionMode={permissionMode} collaborationMode={collaborationMode} agentFlavor={agentFlavor} diff --git a/web/src/components/AssistantChat/StatusBar.test.tsx b/web/src/components/AssistantChat/StatusBar.test.tsx new file mode 100644 index 0000000000..04e4dfc989 --- /dev/null +++ b/web/src/components/AssistantChat/StatusBar.test.tsx @@ -0,0 +1,44 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { StatusBar } from './StatusBar' + +function renderStatusBar(props?: Partial[0]>) { + return render( + + + + ) +} + +describe('StatusBar', () => { + afterEach(() => { + cleanup() + }) + + it('shows Codex reasoning effort and collaboration mode', () => { + renderStatusBar({ + modelReasoningEffort: 'xhigh', + serviceTier: 'fast', + collaborationMode: 'default' + }) + + expect(screen.getByText('xhigh')).toBeInTheDocument() + expect(screen.getByText('fast')).toBeInTheDocument() + expect(screen.getByText('Default')).toBeInTheDocument() + }) + + it('hides Codex config labels when values are unset', () => { + renderStatusBar() + + expect(screen.queryByText('xhigh')).not.toBeInTheDocument() + expect(screen.queryByText('fast')).not.toBeInTheDocument() + expect(screen.queryByText('Default')).not.toBeInTheDocument() + }) +}) diff --git a/web/src/components/AssistantChat/StatusBar.tsx b/web/src/components/AssistantChat/StatusBar.tsx index 4527311bcc..122243b3b6 100644 --- a/web/src/components/AssistantChat/StatusBar.tsx +++ b/web/src/components/AssistantChat/StatusBar.tsx @@ -123,6 +123,8 @@ export function StatusBar(props: { backgroundTaskCount?: number contextSize?: number model?: string | null + modelReasoningEffort?: string | null + serviceTier?: string | null permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode agentFlavor?: string | null @@ -154,12 +156,18 @@ export function StatusBar(props: { const permissionModeLabel = displayPermissionMode ? getPermissionModeLabel(displayPermissionMode) : null const permissionModeTone = displayPermissionMode ? getPermissionModeTone(displayPermissionMode) : null const permissionModeColor = permissionModeTone ? PERMISSION_TONE_CLASSES[permissionModeTone] : 'text-[var(--app-hint)]' - const displayCollaborationMode = props.agentFlavor === 'codex' && props.collaborationMode === 'plan' + const displayCollaborationMode = props.agentFlavor === 'codex' && props.collaborationMode ? props.collaborationMode : null const collaborationModeLabel = displayCollaborationMode ? getCodexCollaborationModeLabel(displayCollaborationMode) : null + const modelReasoningEffort = props.agentFlavor === 'codex' && typeof props.modelReasoningEffort === 'string' + ? props.modelReasoningEffort.trim() + : '' + const serviceTier = props.agentFlavor === 'codex' && typeof props.serviceTier === 'string' + ? props.serviceTier.trim() + : '' return (
@@ -180,6 +188,16 @@ export function StatusBar(props: {
+ {modelReasoningEffort ? ( + + {modelReasoningEffort} + + ) : null} + {serviceTier ? ( + + {serviceTier} + + ) : null} {collaborationModeLabel ? ( {collaborationModeLabel} diff --git a/web/src/components/AssistantChat/codexServiceTierOptions.ts b/web/src/components/AssistantChat/codexServiceTierOptions.ts new file mode 100644 index 0000000000..003c4358e0 --- /dev/null +++ b/web/src/components/AssistantChat/codexServiceTierOptions.ts @@ -0,0 +1,47 @@ +export type CodexComposerServiceTierOption = { + value: string | null + label: string +} + +const CODEX_SERVICE_TIER_PRESETS = ['fast'] as const +const CODEX_SERVICE_TIER_LABELS: Record<(typeof CODEX_SERVICE_TIER_PRESETS)[number], string> = { + fast: 'Fast' +} + +function normalizeCodexServiceTier(serviceTier?: string | null): string | null { + const trimmedServiceTier = serviceTier?.trim().toLowerCase() + if (!trimmedServiceTier || trimmedServiceTier === 'default' || trimmedServiceTier === 'auto') { + return null + } + + return trimmedServiceTier +} + +function formatCodexServiceTierLabel(serviceTier: string): string { + return CODEX_SERVICE_TIER_LABELS[serviceTier as keyof typeof CODEX_SERVICE_TIER_LABELS] + ?? `${serviceTier.charAt(0).toUpperCase()}${serviceTier.slice(1)}` +} + +export function getCodexComposerServiceTierOptions(currentServiceTier?: string | null): CodexComposerServiceTierOption[] { + const normalizedCurrentServiceTier = normalizeCodexServiceTier(currentServiceTier) + const options: CodexComposerServiceTierOption[] = [ + { value: null, label: 'Default' } + ] + + if ( + normalizedCurrentServiceTier + && !CODEX_SERVICE_TIER_PRESETS.includes(normalizedCurrentServiceTier as typeof CODEX_SERVICE_TIER_PRESETS[number]) + ) { + options.push({ + value: normalizedCurrentServiceTier, + label: formatCodexServiceTierLabel(normalizedCurrentServiceTier) + }) + } + + options.push(...CODEX_SERVICE_TIER_PRESETS.map((serviceTier) => ({ + value: serviceTier, + label: CODEX_SERVICE_TIER_LABELS[serviceTier] + }))) + + return options +} diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx new file mode 100644 index 0000000000..863aa55dd4 --- /dev/null +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx @@ -0,0 +1,11 @@ +import type { ToolCallBlock } from '@/chat/types' +import { SubagentPreviewCard } from '@/components/AssistantChat/messages/SubagentPreviewCard' + +export function CodexSubagentPreviewCard(props: { block: ToolCallBlock }) { + return ( + + ) +} diff --git a/web/src/components/AssistantChat/messages/SubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/SubagentPreviewCard.test.tsx new file mode 100644 index 0000000000..971d1bd057 --- /dev/null +++ b/web/src/components/AssistantChat/messages/SubagentPreviewCard.test.tsx @@ -0,0 +1,66 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { SubagentPreviewCard } from './SubagentPreviewCard' + +function spawnBlock(props: { + id: string + agentId: string + nickname: string + message?: string +}): ToolCallBlock { + return { + kind: 'tool-call', + id: props.id, + localId: null, + createdAt: 1, + tool: { + id: props.id, + name: 'CodexSpawnAgent', + state: 'completed', + input: { + message: props.message ?? 'Inspect the current behavior' + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: { + agent_id: props.agentId, + nickname: props.nickname + } + }, + children: [] + } +} + +describe('SubagentPreviewCard', () => { + afterEach(() => { + cleanup() + }) + + it('promotes the nickname ahead of the repeated subagent label', () => { + render() + + const button = screen.getByRole('button', { name: /Pasteur/ }) + const title = screen.getByRole('heading', { name: 'Pasteur' }) + const label = screen.getByText('Subagent conversation') + + expect(button.textContent?.indexOf('Pasteur')).toBeLessThan(button.textContent?.indexOf('Subagent conversation') ?? -1) + expect(title.className).toContain('text-base') + expect(label.className).toContain('text-[11px]') + }) + + it('assigns stable different accents to different subagents', () => { + const { container } = render( + <> + + + + ) + + const cards = Array.from(container.querySelectorAll('[data-subagent-accent]')) + expect(cards).toHaveLength(2) + expect(cards[0].getAttribute('data-subagent-accent')).not.toBe(cards[1].getAttribute('data-subagent-accent')) + }) +}) diff --git a/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx new file mode 100644 index 0000000000..7c141052e7 --- /dev/null +++ b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx @@ -0,0 +1,415 @@ +import { useMemo, useState } from 'react' +import type { ToolCallBlock } from '@/chat/types' +import { isObject } from '@hapi/protocol' +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' +import { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog' +import { CliOutputBlock } from '@/components/CliOutputBlock' +import { getEventPresentation } from '@/chat/presentation' +import { MarkdownRenderer } from '@/components/MarkdownRenderer' +import { ToolCard } from '@/components/ToolCard/ToolCard' +import { useHappyChatContext } from '@/components/AssistantChat/context' +import { getInputStringAny, truncate } from '@/lib/toolInputUtils' + +function getSubagentSummary(block: ToolCallBlock): { + title: string + label: string + detail: string + prompt: string | null + promptPreview: string | null +} { + const input = isObject(block.tool.input) ? block.tool.input : null + const result = isObject(block.tool.result) ? block.tool.result : null + + const nickname = result && typeof result.nickname === 'string' && result.nickname.length > 0 + ? result.nickname + : getInputStringAny(input, ['nickname', 'name', 'agent_name']) + const prompt = getInputStringAny(input, ['message', 'messagePreview', 'prompt', 'description']) + + const displayName = nickname && nickname.length > 0 ? nickname : 'Subagent conversation' + const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` + + return { + title: displayName, + label: nickname && nickname.length > 0 ? 'Subagent conversation' : 'Subagent', + detail: countLabel, + prompt: prompt ?? null, + promptPreview: prompt ? truncate(prompt, 72) : null + } +} + +type LifecycleAction = { + type?: string + createdAt?: number + summary?: string +} + +type LifecycleSnapshot = { + status: 'running' | 'waiting' | 'completed' | 'error' | 'closed' + latestText: string | null + agentId: string | null + nickname: string | null + actions: LifecycleAction[] +} + +function isLifecycleStatus(value: unknown): value is LifecycleSnapshot['status'] { + return value === 'running' || value === 'waiting' || value === 'completed' || value === 'error' || value === 'closed' +} + +function getLifecycleCandidate(block: ToolCallBlock): unknown { + if (isObject(block.lifecycle)) return block.lifecycle + const meta = block.meta + if (!isObject(meta)) return null + if (isObject(meta.codexLifecycle)) return meta.codexLifecycle + if (isObject(meta.lifecycle)) return meta.lifecycle + if (isObject(meta.codexAgentLifecycle)) return meta.codexAgentLifecycle + if (isObject(meta.subagent)) return meta.subagent + return meta +} + +function getLifecycleSnapshot(block: ToolCallBlock): LifecycleSnapshot { + const meta = getLifecycleCandidate(block) + const agentIdFromMeta = isObject(meta) && typeof meta.agentId === 'string' ? meta.agentId : null + const nicknameFromMeta = isObject(meta) && typeof meta.nickname === 'string' ? meta.nickname : null + const statusFromMeta = isObject(meta) && isLifecycleStatus(meta.status) ? meta.status : null + const latestTextFromMeta = isObject(meta) && typeof meta.latestText === 'string' + ? meta.latestText + : isObject(meta) && typeof meta.latest === 'string' + ? meta.latest + : isObject(meta) && typeof meta.message === 'string' + ? meta.message + : null + const actionsFromMeta = isObject(meta) && Array.isArray(meta.actions) ? meta.actions : [] + const prompt = getInputStringAny(isObject(block.tool.input) ? block.tool.input : null, ['message', 'messagePreview', 'prompt', 'description']) + const result = isObject(block.tool.result) ? block.tool.result : null + const agentIdFromResult = result && typeof result.agent_id === 'string' ? result.agent_id : null + const nicknameFromResult = result && typeof result.nickname === 'string' ? result.nickname : null + + const status: LifecycleSnapshot['status'] = statusFromMeta ?? ( + block.tool.state === 'completed' + ? 'completed' + : block.tool.state === 'error' + ? 'error' + : block.tool.state === 'pending' + ? 'waiting' + : 'running' + ) + + const latestText = latestTextFromMeta ?? (prompt ? truncate(prompt, 120) : null) + + return { + status, + latestText, + agentId: agentIdFromMeta ?? agentIdFromResult, + nickname: nicknameFromMeta ?? nicknameFromResult, + actions: actionsFromMeta.filter((action): action is LifecycleAction => isObject(action)) + } +} + +function getLifecycleStatusLabel(status: LifecycleSnapshot['status']): string { + if (status === 'waiting') return 'Waiting' + if (status === 'completed') return 'Completed' + if (status === 'error') return 'Error' + if (status === 'closed') return 'Closed' + return 'Running' +} + +function getLifecycleStatusClass(status: LifecycleSnapshot['status']): string { + if (status === 'completed') return 'bg-emerald-100 text-emerald-700 border-emerald-200' + if (status === 'error') return 'bg-red-100 text-red-700 border-red-200' + if (status === 'closed') return 'bg-slate-100 text-slate-700 border-slate-200' + if (status === 'waiting') return 'bg-amber-100 text-amber-700 border-amber-200' + return 'bg-blue-100 text-blue-700 border-blue-200' +} + +const SUBAGENT_ACCENTS = [ + { + name: 'emerald', + card: 'border-l-emerald-500 hover:border-l-emerald-600', + badge: 'border-emerald-300 bg-emerald-100 text-emerald-800' + }, + { + name: 'cyan', + card: 'border-l-cyan-500 hover:border-l-cyan-600', + badge: 'border-cyan-300 bg-cyan-100 text-cyan-800' + }, + { + name: 'amber', + card: 'border-l-amber-500 hover:border-l-amber-600', + badge: 'border-amber-300 bg-amber-100 text-amber-800' + }, + { + name: 'rose', + card: 'border-l-rose-500 hover:border-l-rose-600', + badge: 'border-rose-300 bg-rose-100 text-rose-800' + }, + { + name: 'indigo', + card: 'border-l-indigo-500 hover:border-l-indigo-600', + badge: 'border-indigo-300 bg-indigo-100 text-indigo-800' + }, + { + name: 'lime', + card: 'border-l-lime-500 hover:border-l-lime-600', + badge: 'border-lime-300 bg-lime-100 text-lime-800' + } +] as const + +function getSubagentAccent(seed: string | null) { + const value = seed && seed.length > 0 ? seed : 'subagent' + let hash = 0 + for (let i = 0; i < value.length; i += 1) { + hash = (hash * 31 + value.charCodeAt(i)) >>> 0 + } + return SUBAGENT_ACCENTS[hash % SUBAGENT_ACCENTS.length] +} + +function getInitials(name: string): string { + const words = name.trim().split(/\s+/).filter(Boolean) + const letters = words.length > 1 + ? `${words[0][0] ?? ''}${words[1][0] ?? ''}` + : name.trim().slice(0, 2) + return letters.toUpperCase() || 'SA' +} + +function OpenIcon() { + return ( + + ) +} + +function CloseIcon() { + return ( + + ) +} + +function normalizePromptForCompare(text: string): string { + return text.replace(/\s+/g, ' ').trim() +} + +function dedupeLeadingPrompt( + blocks: ToolCallBlock['children'], + prompt: string | null +): ToolCallBlock['children'] { + if (!prompt || blocks.length === 0) return blocks + const [first, ...rest] = blocks + if (first.kind !== 'user-text') return blocks + + const promptNorm = normalizePromptForCompare(prompt) + const firstNorm = normalizePromptForCompare(first.text) + if (!promptNorm || !firstNorm) return blocks + + if (promptNorm === firstNorm || promptNorm.includes(firstNorm) || firstNorm.includes(promptNorm)) { + return rest + } + + return blocks +} + +function SubagentBlockList(props: { blocks: ToolCallBlock['children'] }) { + const ctx = useHappyChatContext() + + return ( +
+ {props.blocks.map((block) => { + if (block.kind === 'user-text') { + return ( +
+
{block.text}
+
+ ) + } + + if (block.kind === 'agent-text') { + return ( +
+ +
+ ) + } + + if (block.kind === 'agent-reasoning') { + return ( +
+ {block.text} +
+ ) + } + + if (block.kind === 'cli-output') { + const alignClass = block.source === 'user' ? 'ml-auto w-full max-w-[92%]' : '' + return ( +
+
+ +
+
+ ) + } + + if (block.kind === 'agent-event') { + const presentation = getEventPresentation(block.event) + return ( +
+
+ + {presentation.icon ? : null} + {presentation.text} + +
+
+ ) + } + + if (block.kind === 'tool-call') { + return ( +
+ + {block.children.length > 0 ? ( +
+ +
+ ) : null} +
+ ) + } + + return null + })} +
+ ) +} + +export function SubagentPreviewCard(props: { block: ToolCallBlock; dialogDescription?: string }) { + const summary = getSubagentSummary(props.block) + const lifecycle = getLifecycleSnapshot(props.block) + const dialogTitle = `${summary.title} - ${summary.label}` + const accentSeed = lifecycle.agentId ?? (summary.title !== 'Subagent conversation' ? summary.title : props.block.tool.id) + const accent = getSubagentAccent(accentSeed) + const actionCount = lifecycle.actions.length + const [open, setOpen] = useState(false) + const dialogBlocks = useMemo( + () => dedupeLeadingPrompt(props.block.children, summary.prompt), + [props.block.children, summary.prompt] + ) + const dialogDescription = props.dialogDescription ?? 'Nested child transcript for this subagent run.' + + return ( + + + + + + + + + + {dialogTitle} + + {dialogDescription} + + +
+
+
+
+ + {getLifecycleStatusLabel(lifecycle.status)} + + {actionCount > 0 ? {actionCount} actions : null} +
+ {summary.prompt ? ( +
+ {summary.prompt} +
+ ) : null} + {lifecycle.latestText ? ( +
+ {lifecycle.latestText} +
+ ) : !summary.prompt && summary.promptPreview ? ( +
+ {summary.promptPreview} +
+ ) : null} +
+ +
+
+
+
+ ) +} diff --git a/web/src/components/AssistantChat/messages/ToolMessage.tsx b/web/src/components/AssistantChat/messages/ToolMessage.tsx index ca1c1f613c..f08a9de551 100644 --- a/web/src/components/AssistantChat/messages/ToolMessage.tsx +++ b/web/src/components/AssistantChat/messages/ToolMessage.tsx @@ -1,12 +1,15 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' import type { ChatBlock } from '@/chat/types' import type { ToolCallBlock } from '@/chat/types' +import type { ReactNode } from 'react' import { isObject, safeStringify } from '@hapi/protocol' import { getEventPresentation } from '@/chat/presentation' import { CodeBlock } from '@/components/CodeBlock' import { MarkdownRenderer } from '@/components/MarkdownRenderer' import { LazyRainbowText } from '@/components/LazyRainbowText' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' +import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' +import { SubagentPreviewCard } from '@/components/AssistantChat/messages/SubagentPreviewCard' import { ToolCard } from '@/components/ToolCard/ToolCard' import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' @@ -45,6 +48,13 @@ function splitTaskChildren(block: ToolCallBlock): { pending: ChatBlock[]; rest: return { pending, rest } } +function createTaskPreviewBlock(block: ToolCallBlock, restChildren: ChatBlock[]): ToolCallBlock { + return { + ...block, + children: restChildren + } +} + function HappyNestedBlockList(props: { blocks: ChatBlock[] }) { @@ -109,44 +119,9 @@ function HappyNestedBlockList(props: { } if (block.kind === 'tool-call') { - const isTask = block.tool.name === 'Task' - const taskChildren = isTask ? splitTaskChildren(block) : null - return (
- - {block.children.length > 0 ? ( - isTask ? ( - <> - {taskChildren && taskChildren.pending.length > 0 ? ( -
- -
- ) : null} - {taskChildren && taskChildren.rest.length > 0 ? ( -
- - Task details ({taskChildren.rest.length}) - -
- -
-
- ) : null} - - ) : ( -
- -
- ) - ) : null} + {renderToolBlock(block, ctx)}
) } @@ -157,6 +132,81 @@ function HappyNestedBlockList(props: { ) } +export function getToolChildRenderMode(block: ToolCallBlock): 'none' | 'task' | 'codex-subagent-preview' | 'inline' { + if (block.children.length === 0) return 'none' + if (block.tool.name === 'Task') return 'task' + if (block.tool.name === 'CodexSpawnAgent') return 'codex-subagent-preview' + return 'inline' +} + +function renderToolBlock( + block: ToolCallBlock, + ctx: ReturnType +): ReactNode { + if (block.tool.name === 'CodexSpawnAgent') { + return + } + + if (block.tool.name === 'Task') { + const taskChildren = splitTaskChildren(block) + const previewBlock = createTaskPreviewBlock(block, taskChildren.rest) + + return ( + <> + {taskChildren.pending.length > 0 ? ( +
+ +
+ ) : null} +
+ +
+ + ) + } + + return ( + <> + + {renderToolChildren(block)} + + ) +} + +function renderToolChildren(block: ToolCallBlock): ReactNode | null { + const mode = getToolChildRenderMode(block) + if (mode === 'none') return null + + if (mode === 'task') { + return ( +
+ +
+ ) + } + + if (mode === 'codex-subagent-preview') { + return ( +
+ +
+ ) + } + + return ( +
+ +
+ ) +} + export function HappyToolMessage(props: ToolCallMessagePartProps) { const ctx = useHappyChatContext() const artifact = props.artifact @@ -199,44 +249,10 @@ export function HappyToolMessage(props: ToolCallMessagePartProps) { } const block = artifact - const isTask = block.tool.name === 'Task' - const taskChildren = isTask ? splitTaskChildren(block) : null return (
- - {block.children.length > 0 ? ( - isTask ? ( - <> - {taskChildren && taskChildren.pending.length > 0 ? ( -
- -
- ) : null} - {taskChildren && taskChildren.rest.length > 0 ? ( -
- - Task details ({taskChildren.rest.length}) - -
- -
-
- ) : null} - - ) : ( -
- -
- ) - ) : null} + {renderToolBlock(block, ctx)}
) } diff --git a/web/src/components/AssistantChat/modelOptions.ts b/web/src/components/AssistantChat/modelOptions.ts index 57271eca0e..afd0bf394a 100644 --- a/web/src/components/AssistantChat/modelOptions.ts +++ b/web/src/components/AssistantChat/modelOptions.ts @@ -16,6 +16,27 @@ function getGeminiModelOptions(currentModel?: string | null): ModelOption[] { return options } +function getCodexModelOptions(currentModel?: string | null): ModelOption[] { + const options = MODEL_OPTIONS.codex.map((m) => ({ + value: m.value === 'auto' ? null : m.value, + label: m.label + })) + const normalized = currentModel?.trim() || null + if (normalized && !options.some((o) => o.value === normalized)) { + options.splice(1, 0, { value: normalized, label: normalized }) + } + return options +} + +function getNextCodexModel(currentModel?: string | null): string | null { + const options = getCodexModelOptions(currentModel) + const currentIndex = options.findIndex((o) => o.value === (currentModel ?? null)) + if (currentIndex === -1) { + return options[0]?.value ?? null + } + return options[(currentIndex + 1) % options.length]?.value ?? null +} + function getNextGeminiModel(currentModel?: string | null): string | null { const options = getGeminiModelOptions(currentModel) const currentIndex = options.findIndex((o) => o.value === (currentModel ?? null)) @@ -26,6 +47,9 @@ function getNextGeminiModel(currentModel?: string | null): string | null { } export function getModelOptionsForFlavor(flavor: string | undefined | null, currentModel?: string | null): ModelOption[] { + if (flavor === 'codex') { + return getCodexModelOptions(currentModel) + } if (flavor === 'gemini') { return getGeminiModelOptions(currentModel) } @@ -33,6 +57,9 @@ export function getModelOptionsForFlavor(flavor: string | undefined | null, curr } export function getNextModelForFlavor(flavor: string | undefined | null, currentModel?: string | null): string | null { + if (flavor === 'codex') { + return getNextCodexModel(currentModel) + } if (flavor === 'gemini') { return getNextGeminiModel(currentModel) } diff --git a/web/src/components/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx new file mode 100644 index 0000000000..4c8649d5fb --- /dev/null +++ b/web/src/components/NewSession/ImportExistingModal.test.tsx @@ -0,0 +1,282 @@ +import { useState } from 'react' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { fireEvent, render, screen } from '@testing-library/react' +import { I18nProvider } from '@/lib/i18n-context' +import { ImportExistingModal } from './ImportExistingModal' + +const useImportableSessionsMock = vi.fn() +const useImportableSessionActionsMock = vi.fn() + +vi.mock('@/hooks/queries/useImportableSessions', () => ({ + useImportableSessions: (...args: unknown[]) => useImportableSessionsMock(...args), +})) + +vi.mock('@/hooks/mutations/useImportableSessionActions', () => ({ + useImportableSessionActions: (...args: unknown[]) => useImportableSessionActionsMock(...args), +})) + +function renderModal(props?: { onOpenSession?: (sessionId: string) => void }) { + return render( + + + + ) +} + +describe('ImportExistingModal', () => { + beforeEach(() => { + vi.clearAllMocks() + useImportableSessionsMock.mockReturnValue({ + sessions: [], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession: vi.fn(), + reimportSession: vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error: null, + }) + }) + + it('shows imported-session actions for Codex by default', () => { + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/session.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Prompt preview', + alreadyImported: true, + importedHapiSessionId: 'hapi-123', + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + + renderModal() + + expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Re-import from source' })).toBeInTheDocument() + expect(useImportableSessionsMock).toHaveBeenCalledWith(expect.anything(), 'codex', true) + expect(useImportableSessionActionsMock).toHaveBeenCalledWith(expect.anything(), 'codex') + }) + + it('shows import action for not-yet-imported sessions', () => { + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-imported-0', + }) + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-2', + cwd: '/tmp/project-2', + timestamp: 456, + transcriptPath: '/tmp/project-2/session.jsonl', + previewTitle: null, + previewPrompt: 'Prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession, + reimportSession: vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error: null, + }) + + renderModal() + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + expect(importSession).toHaveBeenCalledWith('external-2') + }) + + it('opens the imported HAPI session immediately after import succeeds', async () => { + const onOpenSession = vi.fn() + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-imported-1', + }) + + useImportableSessionsMock.mockReturnValue({ + sessions: [{ + agent: 'codex', + externalSessionId: 'external-3', + cwd: '/tmp/project-3', + timestamp: 789, + transcriptPath: '/tmp/project-3/session.jsonl', + previewTitle: 'Imported later', + previewPrompt: 'Prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + }) + useImportableSessionActionsMock.mockReturnValue({ + importSession, + reimportSession: vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error: null, + }) + + renderModal({ onOpenSession }) + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-imported-1') + }) + }) + + it('switches to the Claude tab and loads Claude sessions with the same action model', async () => { + const reimportSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-claude-2', + }) + const onOpenSession = vi.fn() + + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: agent === 'claude' + ? [{ + agent: 'claude', + externalSessionId: 'claude-external-1', + cwd: '/tmp/claude-project', + timestamp: 321, + transcriptPath: '/tmp/claude-project/session.jsonl', + previewTitle: 'Claude imported title', + previewPrompt: 'Claude prompt preview', + alreadyImported: true, + importedHapiSessionId: 'hapi-claude-1', + }] + : [], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + importSession: vi.fn(), + reimportSession: agent === 'claude' ? reimportSession : vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error: null, + })) + + renderModal({ onOpenSession }) + + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + + expect(useImportableSessionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude', true) + expect(useImportableSessionActionsMock).toHaveBeenLastCalledWith(expect.anything(), 'claude') + expect(screen.getByRole('button', { name: 'Open in HAPI' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Re-import from source' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Open in HAPI' })) + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-1') + + fireEvent.click(screen.getByRole('button', { name: 'Re-import from source' })) + expect(reimportSession).toHaveBeenCalledWith('claude-external-1') + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-2') + }) + }) + + it('does not leak Codex action state into the Claude tab', () => { + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: [{ + agent, + externalSessionId: `${agent}-external-1`, + cwd: `/tmp/${agent}-project`, + timestamp: 111, + transcriptPath: `/tmp/${agent}-project/session.jsonl`, + previewTitle: `${agent} title`, + previewPrompt: `${agent} prompt`, + alreadyImported: false, + importedHapiSessionId: null, + }], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => { + const [error] = useState(agent === 'codex' ? 'Codex failed' : null) + return { + importSession: vi.fn(), + reimportSession: vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error, + } + }) + + renderModal() + + expect(screen.getByText('Codex failed')).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + + expect(screen.queryByText('Codex failed')).not.toBeInTheDocument() + expect(screen.getByRole('button', { name: 'Import into HAPI' })).toBeInTheDocument() + }) + + it('imports a Claude session and opens it immediately after success', async () => { + const onOpenSession = vi.fn() + const importSession = vi.fn().mockResolvedValue({ + type: 'success', + sessionId: 'hapi-claude-imported-1', + }) + + useImportableSessionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + sessions: agent === 'claude' + ? [{ + agent: 'claude', + externalSessionId: 'claude-external-2', + cwd: '/tmp/claude-project-2', + timestamp: 654, + transcriptPath: '/tmp/claude-project-2/session.jsonl', + previewTitle: 'Claude import later', + previewPrompt: 'Claude prompt preview', + alreadyImported: false, + importedHapiSessionId: null, + }] + : [], + isLoading: false, + error: null, + refetch: vi.fn(), + })) + useImportableSessionActionsMock.mockImplementation((_api: unknown, agent: 'codex' | 'claude') => ({ + importSession: agent === 'claude' ? importSession : vi.fn(), + reimportSession: vi.fn(), + importingSessionId: null, + reimportingSessionId: null, + error: null, + })) + + renderModal({ onOpenSession }) + fireEvent.click(screen.getByRole('button', { name: 'Claude' })) + fireEvent.click(screen.getByRole('button', { name: 'Import into HAPI' })) + + expect(importSession).toHaveBeenCalledWith('claude-external-2') + + await vi.waitFor(() => { + expect(onOpenSession).toHaveBeenCalledWith('hapi-claude-imported-1') + }) + }) +}) diff --git a/web/src/components/NewSession/ImportExistingModal.tsx b/web/src/components/NewSession/ImportExistingModal.tsx new file mode 100644 index 0000000000..2f49e5e031 --- /dev/null +++ b/web/src/components/NewSession/ImportExistingModal.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useState } from 'react' +import type { ApiClient } from '@/api/client' +import { Button } from '@/components/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useImportableSessionActions } from '@/hooks/mutations/useImportableSessionActions' +import { useImportableSessions } from '@/hooks/queries/useImportableSessions' +import type { ImportableSessionAgent } from '@/types/api' +import { useTranslation } from '@/lib/use-translation' +import { ImportableSessionList } from './ImportableSessionList' + +function ImportExistingAgentPanel(props: { + api: ApiClient + agent: ImportableSessionAgent + open: boolean + search: string + onOpenSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + const { sessions, isLoading, error, refetch } = useImportableSessions(props.api, props.agent, props.open) + const { + importSession, + reimportSession, + importingSessionId, + reimportingSessionId, + error: actionError, + } = useImportableSessionActions(props.api, props.agent) + const [selectedExternalSessionId, setSelectedExternalSessionId] = useState(null) + + const filteredSessions = useMemo(() => { + const query = props.search.trim().toLowerCase() + if (!query) { + return sessions + } + + return sessions.filter((session) => { + const haystacks = [ + session.previewTitle, + session.previewPrompt, + session.cwd, + session.externalSessionId, + ] + return haystacks.some((value) => value?.toLowerCase().includes(query)) + }) + }, [props.search, sessions]) + + useEffect(() => { + if (!props.open) { + setSelectedExternalSessionId(null) + return + } + + if (!filteredSessions.find((session) => session.externalSessionId === selectedExternalSessionId)) { + setSelectedExternalSessionId(filteredSessions[0]?.externalSessionId ?? null) + } + }, [filteredSessions, props.open, selectedExternalSessionId]) + + const handleImport = async (externalSessionId: string) => { + const result = await importSession(externalSessionId) + props.onOpenSession(result.sessionId) + } + + const handleReimport = async (externalSessionId: string) => { + const result = await reimportSession(externalSessionId) + props.onOpenSession(result.sessionId) + } + + return ( +
+
+ +
+ + {isLoading ? ( +
+ {t('newSession.import.loading')} +
+ ) : error ? ( +
+
{error}
+ +
+ ) : filteredSessions.length === 0 ? ( +
+ {sessions.length === 0 + ? t('newSession.import.empty') + : t('newSession.import.emptySearch')} +
+ ) : ( + void handleImport(externalSessionId)} + onReimport={(externalSessionId) => void handleReimport(externalSessionId)} + onOpen={props.onOpenSession} + /> + )} + + {actionError ? ( +
+ {actionError} +
+ ) : null} +
+ ) +} + +export function ImportExistingModal(props: { + api: ApiClient + open: boolean + onOpenChange: (open: boolean) => void + onOpenSession: (sessionId: string) => void +}) { + const { t } = useTranslation() + const [activeTab, setActiveTab] = useState('codex') + const [search, setSearch] = useState('') + + useEffect(() => { + if (!props.open) { + setSearch('') + setActiveTab('codex') + } + }, [props.open]) + + return ( + + +
+ + {t('newSession.import.title')} + + {t('newSession.import.description')} + + + +
+
+ + +
+
+ +
+
+ setSearch(event.target.value)} + placeholder={t('newSession.import.searchPlaceholder')} + className="w-full rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-[var(--app-link)]" + /> +
+ +
+
+
+
+ ) +} diff --git a/web/src/components/NewSession/ImportableSessionList.tsx b/web/src/components/NewSession/ImportableSessionList.tsx new file mode 100644 index 0000000000..d8c40bd81e --- /dev/null +++ b/web/src/components/NewSession/ImportableSessionList.tsx @@ -0,0 +1,137 @@ +import type { ImportableSessionView } from '@/types/api' +import { Button } from '@/components/ui/button' +import { useTranslation } from '@/lib/use-translation' + +function formatTimestamp(timestamp: number | null): string { + if (!timestamp) { + return 'Unknown time' + } + + try { + return new Intl.DateTimeFormat(undefined, { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: 'numeric', + minute: '2-digit', + }).format(timestamp) + } catch { + return 'Unknown time' + } +} + +export function ImportableSessionList(props: { + sessions: ImportableSessionView[] + selectedExternalSessionId: string | null + importingSessionId: string | null + reimportingSessionId: string | null + onSelect: (externalSessionId: string) => void + onImport: (externalSessionId: string) => void + onReimport: (externalSessionId: string) => void + onOpen: (sessionId: string) => void +}) { + const { t } = useTranslation() + const selectedSession = props.sessions.find((session) => session.externalSessionId === props.selectedExternalSessionId) + ?? props.sessions[0] + ?? null + + return ( +
+
+
+ {props.sessions.map((session) => { + const selected = session.externalSessionId === selectedSession?.externalSessionId + return ( + + ) + })} +
+
+ +
+ {selectedSession ? ( + <> +
+
+ {selectedSession.previewTitle ?? selectedSession.previewPrompt ?? selectedSession.externalSessionId} +
+
{selectedSession.cwd ?? t('newSession.import.unknownDirectory')}
+
{formatTimestamp(selectedSession.timestamp)}
+
+ +
+
+
{t('newSession.import.preview')}
+
+ {selectedSession.previewPrompt ?? t('newSession.import.noPreview')} +
+
+
+
{t('newSession.import.transcript')}
+
{selectedSession.transcriptPath}
+
+
+ +
+ {selectedSession.alreadyImported && selectedSession.importedHapiSessionId ? ( + <> + + + + ) : ( + + )} +
+ + ) : null} +
+
+ ) +} diff --git a/web/src/components/NewSession/index.tsx b/web/src/components/NewSession/index.tsx index e0d1dd1c11..dd8f8c2600 100644 --- a/web/src/components/NewSession/index.tsx +++ b/web/src/components/NewSession/index.tsx @@ -25,7 +25,9 @@ import { } from './preferences' import { SessionTypeSelector } from './SessionTypeSelector' import { YoloToggle } from './YoloToggle' +import { ImportExistingModal } from './ImportExistingModal' import { formatRunnerSpawnError } from '../../utils/formatRunnerSpawnError' +import { Button } from '@/components/ui/button' export function NewSession(props: { api: ApiClient @@ -53,6 +55,7 @@ export function NewSession(props: { const [sessionType, setSessionType] = useState('simple') const [worktreeName, setWorktreeName] = useState('') const [directoryCreationConfirmed, setDirectoryCreationConfirmed] = useState(false) + const [isImportOpen, setIsImportOpen] = useState(false) const [error, setError] = useState(null) const worktreeInputRef = useRef(null) @@ -353,6 +356,18 @@ export function NewSession(props: {
) : null} +
+ +
+ + +
) } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 2a60c62b29..f68f1b4d52 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -70,6 +70,7 @@ export function SessionChat(props: { setCollaborationMode, setModel, setModelReasoningEffort, + setServiceTier, setEffort } = useSessionActions( props.api, @@ -265,6 +266,17 @@ export function SessionChat(props: { } }, [setModelReasoningEffort, props.onRefresh, haptic]) + const handleServiceTierChange = useCallback(async (serviceTier: string | null) => { + try { + await setServiceTier(serviceTier) + haptic.notification('success') + props.onRefresh() + } catch (e) { + haptic.notification('error') + console.error('Failed to set service tier:', e) + } + }, [setServiceTier, props.onRefresh, haptic]) + const handleEffortChange = useCallback(async (effort: string | null) => { try { await setEffort(effort) @@ -395,6 +407,7 @@ export function SessionChat(props: { collaborationMode={codexCollaborationModeSupported ? props.session.collaborationMode : undefined} model={props.session.model} modelReasoningEffort={agentFlavor === 'codex' ? props.session.modelReasoningEffort : undefined} + serviceTier={agentFlavor === 'codex' ? props.session.serviceTier : undefined} effort={props.session.effort} agentFlavor={agentFlavor} active={props.session.active} @@ -416,6 +429,11 @@ export function SessionChat(props: { ? handleModelReasoningEffortChange : undefined } + onServiceTierChange={ + agentFlavor === 'codex' && props.session.active && !controlledByUser + ? handleServiceTierChange + : undefined + } onEffortChange={handleEffortChange} onSwitchToRemote={handleSwitchToRemote} onTerminal={props.session.active && terminalSupported ? handleViewTerminal : undefined} diff --git a/web/src/components/SessionList.test.ts b/web/src/components/SessionList.test.ts index b830e8018f..4ff7fe0236 100644 --- a/web/src/components/SessionList.test.ts +++ b/web/src/components/SessionList.test.ts @@ -12,7 +12,11 @@ function makeSession(overrides: Partial & { id: string }): Sessi todoProgress: null, pendingRequestsCount: 0, model: null, + modelReasoningEffort: null, effort: null, + serviceTier: null, + permissionMode: null, + collaborationMode: null, ...overrides } } diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 253b9bd82e..eca4bed2ca 100644 --- a/web/src/components/ToolCard/knownTools.tsx +++ b/web/src/components/ToolCard/knownTools.tsx @@ -26,6 +26,16 @@ function formatChecklistCount(items: ChecklistItem[], noun: string): string | nu return `${items.length} ${noun}${items.length === 1 ? '' : 's'}` } +function getInputTextAny(input: unknown, keys: string[]): string | null { + if (!isObject(input)) return null + for (const key of keys) { + const value = input[key] + if (typeof value === 'string' && value.length > 0) return value + if (typeof value === 'number' && Number.isFinite(value)) return String(value) + } + return null +} + function snakeToTitleWithSpaces(value: string): string { return value .split('_') @@ -157,17 +167,93 @@ export const knownTools: Record typeof part === 'string').join(' ') } + const cwd = getInputStringAny(opts.input, ['cwd']) + if (cwd) return cwd return null }, minimal: true }, + CodexWriteStdin: { + icon: () => , + title: (opts) => { + const interrupt = isObject(opts.input) && opts.input.interrupt === true + const chars = getInputStringAny(opts.input, ['chars', 'charsPreview']) + if (interrupt) return 'Interrupt' + if (chars && chars.length > 0) return 'Send input' + return 'Poll output' + }, + subtitle: (opts) => { + const chars = getInputStringAny(opts.input, ['charsPreview', 'chars']) + if (chars && chars.length > 0) return truncate(chars.replace(/\r?\n/g, ' / '), 80) + const target = getInputTextAny(opts.input, ['target', 'session_id', 'sessionId']) + return target ? `target: ${target}` : 'poll' + }, + minimal: true + }, + CodexSpawnAgent: { + icon: () => , + title: (opts) => { + const name = getInputStringAny(opts.input, ['name', 'agent_name', 'nickname']) + return name ? `Agent: ${name}` : 'Spawn agent' + }, + subtitle: (opts) => { + const message = getInputStringAny(opts.input, ['messagePreview', 'message', 'prompt', 'description']) + if (message) return truncate(message, 120) + const model = getInputStringAny(opts.input, ['model']) + const effort = getInputStringAny(opts.input, ['reasoningEffort', 'reasoning_effort']) + const parts = [model, effort ? `effort: ${effort}` : null].filter((part): part is string => typeof part === 'string' && part.length > 0) + return parts.length > 0 ? parts.join(' / ') : null + }, + minimal: true + }, + CodexWaitAgent: { + icon: () => , + title: (opts) => { + const targets = isObject(opts.input) && Array.isArray(opts.input.targets) + ? opts.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : [] + return targets.length > 1 ? 'Wait for agents' : 'Wait for agent' + }, + subtitle: (opts) => { + const targets = isObject(opts.input) && Array.isArray(opts.input.targets) + ? opts.input.targets.filter((target): target is string => typeof target === 'string' && target.length > 0) + : [] + const timeout = getInputTextAny(opts.input, ['timeout_ms', 'timeout']) + const parts: string[] = [] + if (targets.length === 1) parts.push(`target: ${targets[0]}`) + else if (targets.length > 1) parts.push(`${targets.length} targets`) + if (timeout) parts.push(`timeout: ${timeout}`) + return parts.length > 0 ? parts.join(' / ') : null + }, + minimal: true + }, + CodexSendInput: { + icon: () => , + title: (opts) => { + const target = getInputTextAny(opts.input, ['target']) + return target ? `Message: ${target}` : 'Message agent' + }, + subtitle: (opts) => { + const interrupt = isObject(opts.input) && opts.input.interrupt === true + const message = getInputStringAny(opts.input, ['messagePreview', 'message']) + if (message) return truncate(message, 120) + return interrupt ? 'interrupt' : null + }, + minimal: true + }, + CodexCloseAgent: { + icon: () => , + title: () => 'Close agent', + subtitle: (opts) => getInputTextAny(opts.input, ['target', 'agent_id', 'agentId']) ?? null, + minimal: true + }, CodexPermission: { icon: () => , title: (opts) => { const tool = getInputStringAny(opts.input, ['tool']) return tool ? `Permission: ${tool}` : 'Permission request' }, - subtitle: (opts) => getInputStringAny(opts.input, ['message', 'command']) ?? null, + subtitle: (opts) => getInputStringAny(opts.input, ['message', 'command', 'cwd']) ?? null, minimal: true }, shell_command: { diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 18a8f4fbe6..30d69a2d23 100644 --- a/web/src/components/ui/dialog.tsx +++ b/web/src/components/ui/dialog.tsx @@ -4,6 +4,7 @@ import { cn } from '@/lib/utils' export const Dialog = DialogPrimitive.Root export const DialogTrigger = DialogPrimitive.Trigger +export const DialogClose = DialogPrimitive.Close export const DialogContent = React.forwardRef< HTMLDivElement, @@ -14,7 +15,7 @@ export const DialogContent = React.forwardRef< Promise + reimportSession: (externalSessionId: string) => Promise + importingSessionId: string | null + reimportingSessionId: string | null + error: string | null +} { + const queryClient = useQueryClient() + + const invalidate = async (result?: ExternalSessionActionResponse) => { + const tasks: Array> = [ + queryClient.invalidateQueries({ queryKey: queryKeys.sessions }), + queryClient.invalidateQueries({ queryKey: queryKeys.importableSessions(agent) }), + ] + + if (result?.sessionId) { + tasks.push(queryClient.invalidateQueries({ queryKey: queryKeys.session(result.sessionId) })) + if (api) { + tasks.push(fetchLatestMessages(api, result.sessionId)) + } + } + + await Promise.all(tasks) + } + + const importMutation = useMutation({ + mutationFn: async (externalSessionId: string) => { + if (!api) { + throw new Error('API unavailable') + } + return await api.importExternalSession(agent, externalSessionId) + }, + onSuccess: invalidate, + }) + + const reimportMutation = useMutation({ + mutationFn: async (externalSessionId: string) => { + if (!api) { + throw new Error('API unavailable') + } + return await api.refreshExternalSession(agent, externalSessionId) + }, + onSuccess: invalidate, + }) + + useEffect(() => { + importMutation.reset() + reimportMutation.reset() + }, [agent]) + + return { + importSession: importMutation.mutateAsync, + reimportSession: reimportMutation.mutateAsync, + importingSessionId: importMutation.isPending ? importMutation.variables ?? null : null, + reimportingSessionId: reimportMutation.isPending ? reimportMutation.variables ?? null : null, + error: importMutation.error instanceof Error + ? importMutation.error.message + : reimportMutation.error instanceof Error + ? reimportMutation.error.message + : importMutation.error || reimportMutation.error + ? 'Failed to update importable session' + : null, + } +} diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 1ba664294e..7b273154f1 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -19,6 +19,7 @@ export function useSessionActions( setCollaborationMode: (mode: CodexCollaborationMode) => Promise setModel: (model: string | null) => Promise setModelReasoningEffort: (modelReasoningEffort: string | null) => Promise + setServiceTier: (serviceTier: string | null) => Promise setEffort: (effort: string | null) => Promise renameSession: (name: string) => Promise deleteSession: () => Promise @@ -117,6 +118,22 @@ export function useSessionActions( onSuccess: () => void invalidateSession(), }) + const serviceTierMutation = useMutation({ + mutationFn: async (serviceTier: string | null) => { + if (!api || !sessionId) { + throw new Error('Session unavailable') + } + if (agentFlavor !== 'codex') { + throw new Error('Service tier is only supported for Codex sessions') + } + if (!codexCollaborationModeSupported) { + throw new Error('Service tier is only supported for remote Codex sessions') + } + await api.setServiceTier(sessionId, serviceTier) + }, + onSuccess: () => void invalidateSession(), + }) + const effortMutation = useMutation({ mutationFn: async (effort: string | null) => { if (!api || !sessionId) { @@ -160,6 +177,7 @@ export function useSessionActions( setCollaborationMode: collaborationMutation.mutateAsync, setModel: modelMutation.mutateAsync, setModelReasoningEffort: modelReasoningEffortMutation.mutateAsync, + setServiceTier: serviceTierMutation.mutateAsync, setEffort: effortMutation.mutateAsync, renameSession: renameMutation.mutateAsync, deleteSession: deleteMutation.mutateAsync, @@ -170,6 +188,7 @@ export function useSessionActions( || collaborationMutation.isPending || modelMutation.isPending || modelReasoningEffortMutation.isPending + || serviceTierMutation.isPending || effortMutation.isPending || renameMutation.isPending || deleteMutation.isPending, diff --git a/web/src/hooks/mutations/useSpawnSession.ts b/web/src/hooks/mutations/useSpawnSession.ts index 37f69f61a1..c08e1c054f 100644 --- a/web/src/hooks/mutations/useSpawnSession.ts +++ b/web/src/hooks/mutations/useSpawnSession.ts @@ -10,6 +10,7 @@ type SpawnInput = { model?: string effort?: string modelReasoningEffort?: string + serviceTier?: string yolo?: boolean sessionType?: 'simple' | 'worktree' worktreeName?: string @@ -36,7 +37,8 @@ export function useSpawnSession(api: ApiClient | null): { input.yolo, input.sessionType, input.worktreeName, - input.effort + input.effort, + input.serviceTier ) }, onSuccess: () => { diff --git a/web/src/hooks/queries/useImportableSessions.ts b/web/src/hooks/queries/useImportableSessions.ts new file mode 100644 index 0000000000..d682260050 --- /dev/null +++ b/web/src/hooks/queries/useImportableSessions.ts @@ -0,0 +1,33 @@ +import { useQuery } from '@tanstack/react-query' +import type { ApiClient } from '@/api/client' +import type { ImportableSessionAgent, ImportableSessionView } from '@/types/api' +import { queryKeys } from '@/lib/query-keys' + +export function useImportableSessions( + api: ApiClient | null, + agent: ImportableSessionAgent, + enabled: boolean +): { + sessions: ImportableSessionView[] + isLoading: boolean + error: string | null + refetch: () => Promise +} { + const query = useQuery({ + queryKey: queryKeys.importableSessions(agent), + queryFn: async () => { + if (!api) { + throw new Error('API unavailable') + } + return await api.listImportableSessions(agent) + }, + enabled: Boolean(api) && enabled, + }) + + return { + sessions: query.data?.sessions ?? [], + isLoading: query.isLoading || query.isFetching, + error: query.error instanceof Error ? query.error.message : query.error ? 'Failed to load importable sessions' : null, + refetch: query.refetch, + } +} diff --git a/web/src/hooks/useSSE.ts b/web/src/hooks/useSSE.ts index fe22fc9b79..6b825e1f67 100644 --- a/web/src/hooks/useSSE.ts +++ b/web/src/hooks/useSSE.ts @@ -30,7 +30,7 @@ const RECONNECT_MAX_DELAY_MS = 30_000 const RECONNECT_JITTER_MS = 500 const INVALIDATION_BATCH_MS = 16 -type SessionPatch = Partial> +type SessionPatch = Partial> function sortSessionSummaries(left: SessionSummary, right: SessionSummary): number { if (left.active !== right.active) { @@ -93,6 +93,10 @@ function getSessionPatch(value: unknown): SessionPatch | null { patch.effort = value.effort hasKnownPatch = true } + if (value.serviceTier === null || typeof value.serviceTier === 'string') { + patch.serviceTier = value.serviceTier + hasKnownPatch = true + } if (typeof value.permissionMode === 'string') { patch.permissionMode = value.permissionMode as Session['permissionMode'] hasKnownPatch = true @@ -109,7 +113,7 @@ function hasUnknownSessionPatchKeys(value: unknown): boolean { if (!hasRecordShape(value)) { return false } - const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'modelReasoningEffort', 'effort', 'permissionMode', 'collaborationMode']) + const knownKeys = new Set(['active', 'thinking', 'activeAt', 'updatedAt', 'model', 'modelReasoningEffort', 'effort', 'serviceTier', 'permissionMode', 'collaborationMode']) return Object.keys(value).some((key) => !knownKeys.has(key)) } @@ -395,7 +399,11 @@ export function useSSE(options: { activeAt: patch.activeAt ?? current.activeAt, updatedAt: patch.updatedAt ?? current.updatedAt, model: Object.prototype.hasOwnProperty.call(patch, 'model') ? patch.model ?? null : current.model, - effort: Object.prototype.hasOwnProperty.call(patch, 'effort') ? patch.effort ?? null : current.effort + modelReasoningEffort: Object.prototype.hasOwnProperty.call(patch, 'modelReasoningEffort') ? patch.modelReasoningEffort ?? null : current.modelReasoningEffort, + effort: Object.prototype.hasOwnProperty.call(patch, 'effort') ? patch.effort ?? null : current.effort, + serviceTier: Object.prototype.hasOwnProperty.call(patch, 'serviceTier') ? patch.serviceTier ?? null : current.serviceTier, + permissionMode: Object.prototype.hasOwnProperty.call(patch, 'permissionMode') ? patch.permissionMode ?? null : current.permissionMode, + collaborationMode: Object.prototype.hasOwnProperty.call(patch, 'collaborationMode') ? patch.collaborationMode ?? null : current.collaborationMode } patched = true diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 77b5e1fe03..62d3920689 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -111,6 +111,28 @@ export default { 'newSession.yolo.desc': 'Uses dangerous agent flags when spawning.', 'newSession.create': 'Create', 'newSession.creating': 'Creating…', + 'newSession.import.entry': 'Import Existing', + 'newSession.import.title': 'Import Existing', + 'newSession.import.description': 'Browse local Codex or Claude sessions and import or re-import them without leaving HAPI.', + 'newSession.import.tabs.claude': 'Claude', + 'newSession.import.searchPlaceholder': 'Search imported titles, prompts, or paths', + 'newSession.import.refreshList': 'Refresh', + 'newSession.import.loading': 'Loading importable sessions...', + 'newSession.import.retry': 'Retry', + 'newSession.import.empty': 'No importable sessions were found on the connected machine.', + 'newSession.import.emptySearch': 'No sessions match your search.', + 'newSession.import.badgeImported': 'Imported', + 'newSession.import.badgeReady': 'Ready', + 'newSession.import.unknownDirectory': 'Unknown directory', + 'newSession.import.preview': 'Preview', + 'newSession.import.noPreview': 'No prompt preview available.', + 'newSession.import.transcript': 'Transcript', + 'newSession.import.open': 'Open in HAPI', + 'newSession.import.reimport': 'Re-import from source', + 'newSession.import.reimporting': 'Re-importing...', + 'newSession.import.cta': 'Import into HAPI', + 'newSession.import.importing': 'Importing...', + // Spawn session (old component) 'spawn.title': 'Create Session', @@ -279,6 +301,7 @@ export default { 'misc.permissionMode': 'Permission Mode', 'misc.model': 'Model', 'misc.reasoningEffort': 'Reasoning Effort', + 'misc.serviceTier': 'Service Tier', 'misc.effort': 'Effort', 'misc.loading': 'Loading…', 'misc.loadOlder': 'Load older', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ca698dce30..77b428f3c4 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -113,6 +113,27 @@ export default { 'newSession.yolo.desc': '启动时使用危险的代理标志。', 'newSession.create': '创建', 'newSession.creating': '创建中…', + 'newSession.import.entry': '导入现有会话', + 'newSession.import.title': '导入现有会话', + 'newSession.import.description': '浏览本地 Codex 或 Claude 会话,并在不离开 HAPI 的情况下导入或重新导入它们。', + 'newSession.import.tabs.claude': 'Claude', + 'newSession.import.searchPlaceholder': '搜索标题、提示词或路径', + 'newSession.import.refreshList': '刷新', + 'newSession.import.loading': '正在加载可导入的会话...', + 'newSession.import.retry': '重试', + 'newSession.import.empty': '当前连接机器上没有可导入的会话。', + 'newSession.import.emptySearch': '没有匹配搜索条件的会话。', + 'newSession.import.badgeImported': '已导入', + 'newSession.import.badgeReady': '可导入', + 'newSession.import.unknownDirectory': '未知目录', + 'newSession.import.preview': '预览', + 'newSession.import.noPreview': '没有可用的提示词预览。', + 'newSession.import.transcript': '转录文件', + 'newSession.import.open': '在 HAPI 中打开', + 'newSession.import.reimport': '重新从源导入', + 'newSession.import.reimporting': '重新导入中...', + 'newSession.import.cta': '导入到 HAPI', + 'newSession.import.importing': '导入中...', // Spawn session (old component) 'spawn.title': '创建会话', @@ -281,6 +302,7 @@ export default { 'misc.permissionMode': '权限模式', 'misc.model': '模型', 'misc.reasoningEffort': '推理强度', + 'misc.serviceTier': '服务档位', 'misc.effort': '思考强度', 'misc.loading': '加载中…', 'misc.loadOlder': '加载更早的', diff --git a/web/src/lib/message-window-store.test.ts b/web/src/lib/message-window-store.test.ts new file mode 100644 index 0000000000..b15910bab4 --- /dev/null +++ b/web/src/lib/message-window-store.test.ts @@ -0,0 +1,65 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DecryptedMessage, MessageStatus } from '@/types/api' + +function userMessage(input: { + id: string + seq: number | null + localId: string | null + text: string + status?: MessageStatus +}): DecryptedMessage { + return { + id: input.id, + seq: input.seq, + localId: input.localId, + createdAt: 1_000, + content: { + role: 'user', + content: { + type: 'text', + text: input.text + } + }, + status: input.status, + originalText: input.text + } +} + +describe('message-window-store', () => { + beforeEach(() => { + vi.resetModules() + }) + + it('updates status after a stored message replaces its optimistic bubble', async () => { + const store = await import('./message-window-store') + const sessionId = 'session-1' + const localId = 'local-1' + + store.appendOptimisticMessage(sessionId, userMessage({ + id: localId, + seq: null, + localId, + text: 'hello', + status: 'sending' + })) + + store.ingestIncomingMessages(sessionId, [ + userMessage({ + id: 'message-1', + seq: 1, + localId, + text: 'hello' + }) + ]) + + store.updateMessageStatus(sessionId, localId, 'sent') + + const state = store.getMessageWindowState(sessionId) + expect(state.messages).toHaveLength(1) + expect(state.messages[0]).toMatchObject({ + id: 'message-1', + localId, + status: 'sent' + }) + }) +}) diff --git a/web/src/lib/message-window-store.ts b/web/src/lib/message-window-store.ts index a795c41b39..4b640ffb24 100644 --- a/web/src/lib/message-window-store.ts +++ b/web/src/lib/message-window-store.ts @@ -519,7 +519,7 @@ export function updateMessageStatus(sessionId: string, localId: string, status: let changed = false const updateList = (list: DecryptedMessage[]) => { return list.map((message) => { - if (message.localId !== localId || !isOptimisticMessage(message)) { + if (message.localId !== localId) { return message } if (message.status === status) { diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a00b5512b7..4930336453 100644 --- a/web/src/lib/query-keys.ts +++ b/web/src/lib/query-keys.ts @@ -1,8 +1,11 @@ +import type { ImportableSessionAgent } from '@/types/api' + export const queryKeys = { sessions: ['sessions'] as const, session: (sessionId: string) => ['session', sessionId] as const, messages: (sessionId: string) => ['messages', sessionId] as const, machines: ['machines'] as const, + importableSessions: (agent: ImportableSessionAgent) => ['importable-sessions', agent] as const, gitStatus: (sessionId: string) => ['git-status', sessionId] as const, sessionFiles: (sessionId: string, query: string) => ['session-files', sessionId, query] as const, sessionDirectory: (sessionId: string, path: string) => ['session-directory', sessionId, path] as const, diff --git a/web/src/types/api.ts b/web/src/types/api.ts index ba03f262be..95975533d4 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -22,6 +22,8 @@ export type { WorktreeMetadata } from '@hapi/protocol/types' +import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' + export type SessionMetadataSummary = { path: string host: string @@ -95,6 +97,37 @@ export type MessagesResponse = { export type MachinesResponse = { machines: Machine[] } export type MachinePathsExistsResponse = { exists: Record } +export type ImportableSessionAgent = 'codex' | 'claude' + +export type ImportableSessionView = { + agent: ImportableSessionAgent + externalSessionId: string + cwd: string | null + timestamp: number | null + transcriptPath: string + previewTitle: string | null + previewPrompt: string | null + model?: string | null + effort?: string | null + modelReasoningEffort?: string | null + serviceTier?: string | null + collaborationMode?: CodexCollaborationMode | null + approvalPolicy?: string | null + sandboxPolicy?: unknown | null + permissionMode?: PermissionMode | null + alreadyImported: boolean + importedHapiSessionId: string | null +} + +export type ImportableSessionsResponse = { + sessions: ImportableSessionView[] +} + +export type ExternalSessionActionResponse = { + type: 'success' + sessionId: string +} + export type SpawnResponse = | { type: 'success'; sessionId: string } | { type: 'error'; message: string }