diff --git a/cli/src/agent/loopBase.ts b/cli/src/agent/loopBase.ts index 6e2c35bd2..a7f19b05e 100644 --- a/cli/src/agent/loopBase.ts +++ b/cli/src/agent/loopBase.ts @@ -3,7 +3,7 @@ import type { AgentSessionBase } from './sessionBase'; export type LoopLauncher = (session: TSession) => Promise<'switch' | 'exit'>; -export async function runLocalRemoteSession>(opts: { +export async function runLocalRemoteSession>(opts: { session: TSession; startingMode?: 'local' | 'remote'; logTag: string; @@ -24,7 +24,7 @@ export async function runLocalRemoteSession>(opts: { +export async function runLocalRemoteLoop>(opts: { session: TSession; startingMode?: 'local' | 'remote'; logTag: string; diff --git a/cli/src/agent/sessionBase.ts b/cli/src/agent/sessionBase.ts index fabe17f26..d5b295aec 100644 --- a/cli/src/agent/sessionBase.ts +++ b/cli/src/agent/sessionBase.ts @@ -3,13 +3,13 @@ import { MessageQueue2 } from '@/utils/MessageQueue2'; import type { Metadata, SessionCollaborationMode, SessionEffort, SessionModel, SessionPermissionMode } from '@/api/types'; import { logger } from '@/ui/logger'; -export type AgentSessionBaseOptions = { +export type AgentSessionBaseOptions = { api: ApiClient; client: ApiSessionClient; path: string; logPath: string; sessionId: string | null; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; sessionLabel: string; @@ -21,12 +21,12 @@ export type AgentSessionBaseOptions = { collaborationMode?: SessionCollaborationMode; }; -export class AgentSessionBase { +export class AgentSessionBase { readonly path: string; readonly logPath: string; readonly api: ApiClient; readonly client: ApiSessionClient; - readonly queue: MessageQueue2; + readonly queue: MessageQueue2; protected readonly _onModeChange: (mode: 'local' | 'remote') => void; sessionId: string | null; @@ -43,7 +43,7 @@ export class AgentSessionBase { protected effort?: SessionEffort; protected collaborationMode?: SessionCollaborationMode; - constructor(opts: AgentSessionBaseOptions) { + constructor(opts: AgentSessionBaseOptions) { this.path = opts.path; this.api = opts.api; this.client = opts.client; diff --git a/cli/src/api/apiMachine.test.ts b/cli/src/api/apiMachine.test.ts new file mode 100644 index 000000000..84f9438d2 --- /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 42a25f835..33819b530 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' interface ServerToRunnerEvents { @@ -79,25 +86,7 @@ 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 { @@ -154,6 +143,42 @@ 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/types.ts b/cli/src/api/types.ts index 58bf1ca5f..e8682a3e9 100644 --- a/cli/src/api/types.ts +++ b/cli/src/api/types.ts @@ -131,7 +131,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/claudeRemote.ts b/cli/src/claude/claudeRemote.ts index 038b037fd..2ce6c5f4b 100644 --- a/cli/src/claude/claudeRemote.ts +++ b/cli/src/claude/claudeRemote.ts @@ -11,6 +11,7 @@ import { systemPrompt } from "./utils/systemPrompt"; import { PermissionResult } from "./sdk/types"; import { getHapiBlobsDir } from "@/constants/uploadPaths"; import { getDefaultClaudeCodePath } from "./sdk/utils"; +import { extractExplicitResumeSessionId } from "./utils/explicitResume"; export async function claudeRemote(opts: { @@ -47,27 +48,12 @@ export async function claudeRemote(opts: { // Extract --resume from claudeArgs if present (for first spawn) if (!startFrom && opts.claudeArgs) { - for (let i = 0; i < opts.claudeArgs.length; i++) { - if (opts.claudeArgs[i] === '--resume') { - // Check if next arg exists and looks like a session ID - if (i + 1 < opts.claudeArgs.length) { - const nextArg = opts.claudeArgs[i + 1]; - // If next arg doesn't start with dash and contains dashes, it's likely a UUID - if (!nextArg.startsWith('-') && nextArg.includes('-')) { - startFrom = nextArg; - logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); - break; - } else { - // Just --resume without UUID - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } else { - // --resume at end of args - SDK doesn't support this - logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); - break; - } - } + const explicitResumeSessionId = extractExplicitResumeSessionId(opts.claudeArgs); + if (explicitResumeSessionId) { + startFrom = explicitResumeSessionId; + logger.debug(`[claudeRemote] Found --resume with session ID: ${startFrom}`); + } else if (opts.claudeArgs.includes('--resume')) { + logger.debug('[claudeRemote] Found --resume without session ID - not supported in remote mode'); } } diff --git a/cli/src/claude/claudeRemoteLauncher.test.ts b/cli/src/claude/claudeRemoteLauncher.test.ts new file mode 100644 index 000000000..5e4f777ac --- /dev/null +++ b/cli/src/claude/claudeRemoteLauncher.test.ts @@ -0,0 +1,421 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { RawJSONLinesSchema } from './types' + +const harness = vi.hoisted(() => ({ + replayMessages: [] as Array>, + remoteMessages: [] as Array>, + scannerCalls: [] as Array>, + remoteCalls: [] as Array>, + metadataUpdates: [] as Array>, + sessionEvents: [] as Array>, + replayMetaMessages: [] as Array>, + rpcHandlers: new Map Promise | unknown>(), + expectedReplaySessionId: 'resume-session-123', +})) + +vi.mock('./claudeRemote', () => ({ + claudeRemote: async (opts: { + onMessage: (message: Record) => void + }) => { + harness.remoteCalls.push(opts as Record) + 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) + } + 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 sessionEvents: 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) => { + sessionEvents.push(event) + 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 + 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, + sessionEvents + } +} + +describe('claudeRemoteLauncher', () => { + afterEach(() => { + harness.replayMessages = [] + harness.remoteMessages = [] + harness.scannerCalls = [] + harness.remoteCalls = [] + harness.metadataUpdates = [] + harness.sessionEvents = [] + harness.replayMetaMessages = [] + harness.rpcHandlers = new Map() + harness.expectedReplaySessionId = 'resume-session-123' + }) + + it('replays transcript history before live remote Claude messages', async () => { + harness.replayMessages = [ + { type: 'user', uuid: 'u1', message: { content: 'existing user prompt' } }, + { type: 'assistant', uuid: 'a1', message: { content: [{ type: 'text', text: 'existing assistant reply' }] } }, + { + type: 'assistant', + uuid: 'child-a1', + isSidechain: true, + sessionId: 'child-session-1', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + content: [{ type: 'text', text: 'linked child replay reply' }] + } + } + ] + + const { session: liveSession, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(liveSession as never) + + expect(harness.scannerCalls).toEqual([ + expect.objectContaining({ + sessionId: 'resume-session-123', + replayExistingMessages: true + }) + ]) + expect(sentClaudeMessages.slice(0, 4)).toEqual([ + expect.objectContaining({ + type: 'user', + message: expect.objectContaining({ content: 'existing user prompt' }) + }), + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'existing assistant reply' }] + }) + }), + expect.objectContaining({ + type: 'assistant', + isSidechain: true, + sessionId: 'child-session-1', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }), + message: expect.objectContaining({ + content: [{ type: 'text', text: 'linked child replay reply' }] + }) + }), + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'live assistant reply' }] + }) + }) + ]) + }) + + 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)).toEqual([ + expect.objectContaining({ + type: 'assistant', + message: expect.objectContaining({ + content: [{ type: 'text', text: 'live assistant reply' }] + }) + }) + ]) + }) + + it('forwards Claude subagent metadata from live messages and replayed transcript entries', async () => { + harness.remoteMessages = [ + { + type: 'assistant', + parent_tool_use_id: 'task-1', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + }, + { + type: 'result', + subtype: 'success', + result: 'done', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'task-1' + } + ] + + const { session, sentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(session as never) + + expect(harness.metadataUpdates).toEqual([]) + expect(harness.sessionEvents).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'subagent_title_change', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }), + expect.objectContaining({ + type: 'subagent_status_change', + sidechainKey: 'task-1', + status: 'completed' + }) + ])) + + const persistedReplayMeta = sentClaudeMessages.find((message) => { + if (message.type !== 'system' || message.isMeta !== true) { + return false + } + + const subagent = (message.meta as Record | undefined)?.subagent + return Array.isArray(subagent) + }) + + expect(persistedReplayMeta).toEqual(expect.objectContaining({ + type: 'system', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }) + ]) + }) + })) + + harness.replayMessages = [persistedReplayMeta as Record] + harness.metadataUpdates = [] + harness.sessionEvents = [] + harness.remoteMessages = [] + const { session: replaySession, sentClaudeMessages: replaySentClaudeMessages } = createSessionStub() + + await claudeRemoteLauncher(replaySession as never) + + expect(harness.metadataUpdates).toEqual([]) + expect(harness.sessionEvents).toEqual([ + expect.objectContaining({ + type: 'subagent_status_change', + sidechainKey: 'task-1', + status: 'completed' + }), + expect.objectContaining({ + type: 'subagent_title_change', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }) + ]) + expect(replaySentClaudeMessages.some((message) => message.type === 'system' && message.isMeta === true)).toBe(false) + + expect(sentClaudeMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'assistant', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }) + ])) + + expect(harness.scannerCalls).toHaveLength(2) + }) +}) diff --git a/cli/src/claude/claudeRemoteLauncher.ts b/cli/src/claude/claudeRemoteLauncher.ts index c5f4a327f..3a26264c8 100644 --- a/cli/src/claude/claudeRemoteLauncher.ts +++ b/cli/src/claude/claudeRemoteLauncher.ts @@ -11,12 +11,16 @@ 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 { createClaudeSubagentAdapter } from "./utils/claudeSubagentAdapter"; import type { ClaudePermissionMode } from "@hapi/protocol/types"; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from "@/modules/common/remote/RemoteLauncherBase"; +import type { NormalizedSubagentMeta } from "@/subagents/types"; interface PermissionsField { date: number; @@ -79,6 +83,38 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { }); } + private forwardSubagentMeta(meta: NormalizedSubagentMeta[]): void { + for (const item of meta) { + if (item.kind === 'spawn') { + if (item.prompt) { + this.session.client.sendSessionEvent({ + type: 'subagent_title_change', + sidechainKey: item.sidechainKey, + title: item.prompt + } as any); + } + continue; + } + + if (item.kind === 'title') { + this.session.client.sendSessionEvent({ + type: 'subagent_title_change', + sidechainKey: item.sidechainKey, + title: item.title ?? item.sidechainKey + } as any); + continue; + } + + if (item.kind === 'status') { + this.session.client.sendSessionEvent({ + type: 'subagent_status_change', + sidechainKey: item.sidechainKey, + status: item.status + } as any); + } + } + } + protected async runMainLoop(): Promise { logger.debug('[claudeRemoteLauncher] Starting remote launcher'); logger.debug(`[claudeRemoteLauncher] TTY available: ${this.hasTTY}`); @@ -102,11 +138,58 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { messageQueue.releaseToolCall(toolCallId); }); + const subagentAdapter = createClaudeSubagentAdapter(); + const replaySubagentAdapter = createClaudeSubagentAdapter(); + const sdkToLogConverter = new SDKToLogConverter({ sessionId: session.sessionId || 'unknown', cwd: session.path, version: process.env.npm_package_version - }, permissionHandler.getResponses()); + }, permissionHandler.getResponses(), subagentAdapter); + + const forwardSubagentMeta = (meta: NormalizedSubagentMeta[]): void => { + this.forwardSubagentMeta(meta); + }; + + const replayExplicitResumeTranscript = async (): Promise => { + if (session.startingMode !== 'remote') { + return; + } + + const resumeSessionId = session.consumeExplicitRemoteResumeReplaySessionId(); + if (!resumeSessionId) { + return; + } + + const scanner = await createSessionScanner({ + sessionId: resumeSessionId, + workingDirectory: session.path, + replayExistingMessages: true, + onMessage: (message) => { + const subagentMeta = (message as Record).meta as { + subagent?: NormalizedSubagentMeta | NormalizedSubagentMeta[] + } | undefined; + + if (subagentMeta?.subagent) { + forwardSubagentMeta(Array.isArray(subagentMeta.subagent) + ? subagentMeta.subagent + : [subagentMeta.subagent]); + } + + forwardSubagentMeta(replaySubagentAdapter.extract(message as SDKMessage)); + + if (message.type === 'summary' || message.isMeta || message.isCompactSummary) { + return; + } + if (!isClaudeChatVisibleMessage(message)) { + return; + } + session.client.sendClaudeSessionMessage(message); + } + }); + + await scanner.cleanup(); + }; const handleSessionFound = (sessionId: string) => { sdkToLogConverter.updateSessionId(sessionId); @@ -120,6 +203,8 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { function onMessage(message: SDKMessage) { formatClaudeMessageForInk(message, messageBuffer); permissionHandler.onMessage(message); + const subagentMeta = subagentAdapter.extract(message); + forwardSubagentMeta(subagentMeta); if (message.type === 'assistant') { let umessage = message as SDKAssistantMessage; @@ -187,8 +272,13 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { } } - const logMessage = sdkToLogConverter.convert(msg); + const logMessage = sdkToLogConverter.convert(msg, subagentMeta); if (logMessage) { + if (logMessage.isMeta === true) { + session.client.sendClaudeSessionMessage(logMessage); + return; + } + if (logMessage.type === 'user' && logMessage.message?.content) { const content = Array.isArray(logMessage.message.content) ? logMessage.message.content @@ -276,6 +366,10 @@ class ClaudeRemoteLauncher extends RemoteLauncherBase { while (!this.exitReason) { logger.debug('[remote]: launch'); messageBuffer.addMessage('═'.repeat(40), 'status'); + subagentAdapter.reset(); + replaySubagentAdapter.reset(); + + await replayExplicitResumeTranscript(); const isNewSession = session.sessionId !== previousSessionId; if (isNewSession) { diff --git a/cli/src/claude/session.ts b/cli/src/claude/session.ts index 975bcb2da..7203f0f44 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/types.ts b/cli/src/claude/types.ts index 5a61d9482..0827218b7 100644 --- a/cli/src/claude/types.ts +++ b/cli/src/claude/types.ts @@ -32,6 +32,9 @@ const RawJSONLinesBaseSchema = z.object({ version: z.string().optional(), gitBranch: z.string().optional(), timestamp: z.string().optional(), + meta: z.object({ + subagent: z.unknown().optional(), + }).passthrough().optional(), }); // Main schema with validation for the fields used in the app diff --git a/cli/src/claude/utils/claudeSubagentAdapter.test.ts b/cli/src/claude/utils/claudeSubagentAdapter.test.ts new file mode 100644 index 000000000..613c0fc69 --- /dev/null +++ b/cli/src/claude/utils/claudeSubagentAdapter.test.ts @@ -0,0 +1,162 @@ +import { describe, expect, it } from 'vitest' +import { createClaudeSubagentAdapter } from './claudeSubagentAdapter' + +describe('claudeSubagentAdapter', () => { + it('derives normalized Claude subagent spawn metadata from Task tool use', () => { + const adapter = createClaudeSubagentAdapter() + + expect(adapter.extract({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as any)).toEqual([{ + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate test failure' + }]) + }) + + it('preserves the same sidechain key for assistant and user sidechain messages', () => { + const adapter = createClaudeSubagentAdapter() + + expect(adapter.extract({ + type: 'assistant', + parent_tool_use_id: 'task-1', + message: { + content: [{ type: 'text', text: 'working the sidechain' }] + } + } as any)).toEqual([{ + kind: 'message', + sidechainKey: 'task-1' + }]) + + expect(adapter.extract({ + type: 'user', + parent_tool_use_id: 'task-1', + message: { + role: 'user', + content: 'sidechain user reply' + } + } as any)).toEqual([{ + kind: 'message', + sidechainKey: 'task-1' + }]) + }) + + it('retains the Task prompt for later lifecycle title fallback', () => { + const adapter = createClaudeSubagentAdapter() + + adapter.extract({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as any) + + expect(adapter.extract({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-1' + } as any)).toEqual([ + { + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }, + { + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + } + ]) + }) + + it('falls back to session id as title text when the task prompt is unavailable', () => { + const adapter = createClaudeSubagentAdapter() + + adapter.extract({ + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: {} + }] + } + } as any) + + expect(adapter.extract({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-2' + } as any)).toEqual([ + { + kind: 'status', + sidechainKey: 'task-2', + status: 'completed' + }, + { + kind: 'title', + sidechainKey: 'task-2', + title: 'claude-session-2' + } + ]) + }) + + it('does not guess a sidechain key from result.session_id when multiple Task sidechains are active', () => { + const adapter = createClaudeSubagentAdapter() + + adapter.extract({ + type: 'assistant', + message: { + content: [ + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Task 1' } + }, + { + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'Task 2' } + } + ] + } + } as any) + + expect(adapter.extract({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-3' + } as any)).toEqual([]) + }) +}) diff --git a/cli/src/claude/utils/claudeSubagentAdapter.ts b/cli/src/claude/utils/claudeSubagentAdapter.ts new file mode 100644 index 000000000..fa360aac3 --- /dev/null +++ b/cli/src/claude/utils/claudeSubagentAdapter.ts @@ -0,0 +1,133 @@ +import { createSpawnMeta, createStatusMeta } from '@/subagents/normalize' +import type { NormalizedSubagentMeta } from '@/subagents/types' +import type { SDKAssistantMessage, SDKMessage } from '@/claude/sdk' + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? value as Record : null +} + +function asString(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null +} + +function extractPrompt(input: unknown): string | undefined { + const record = asRecord(input) + if (!record) { + return asString(input) ?? undefined + } + + return asString(record.prompt) + ?? asString(record.title) + ?? asString(record.message) + ?? asString(record.text) + ?? asString(record.content) + ?? undefined +} + +function getParentToolUseId(message: SDKMessage): string | null { + return asString((message as SDKAssistantMessage).parent_tool_use_id) + ?? asString((message as Record).parentToolUseId) + ?? null +} + +export class ClaudeSubagentAdapter { + private readonly promptBySidechainKey = new Map() + private readonly activeTaskSidechainKeys = new Set() + + reset(): void { + this.promptBySidechainKey.clear() + this.activeTaskSidechainKeys.clear() + } + + extract(message: SDKMessage): NormalizedSubagentMeta[] { + const metas: NormalizedSubagentMeta[] = [] + + if (message.type === 'assistant') { + const assistantMessage = message as SDKAssistantMessage + const content = assistantMessage.message.content + + if (Array.isArray(content)) { + for (const block of content) { + if (block.type !== 'tool_use' || block.name !== 'Task' || !block.id) { + continue + } + + const prompt = extractPrompt(block.input) + if (prompt) { + this.promptBySidechainKey.set(block.id, prompt) + } + this.activeTaskSidechainKeys.add(block.id) + metas.push(createSpawnMeta({ + sidechainKey: block.id, + prompt + })) + } + } + + const sidechainKey = getParentToolUseId(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) + } + + return metas + } + + if (message.type === 'user') { + const sidechainKey = getParentToolUseId(message) + if (sidechainKey) { + metas.push({ + kind: 'message', + sidechainKey + }) + } + + return metas + } + + if (message.type !== 'result') { + return metas + } + + const explicitParentToolUseId = getParentToolUseId(message) + const sidechainKey = explicitParentToolUseId ?? this.getSafeImplicitResultSidechainKey() + if (!sidechainKey) { + return metas + } + + metas.push(createStatusMeta({ + sidechainKey, + status: message.subtype === 'success' ? 'completed' : 'error' + })) + + const title = this.promptBySidechainKey.get(sidechainKey) + ?? asString((message as Record).session_id) + ?? asString((message as Record).sessionId) + + if (title) { + metas.push({ + kind: 'title', + sidechainKey, + title + }) + } + + this.activeTaskSidechainKeys.delete(sidechainKey) + + return metas + } + + private getSafeImplicitResultSidechainKey(): string | null { + if (this.activeTaskSidechainKeys.size !== 1) { + return null + } + + return this.activeTaskSidechainKeys.values().next().value ?? null + } +} + +export function createClaudeSubagentAdapter(): ClaudeSubagentAdapter { + return new ClaudeSubagentAdapter() +} diff --git a/cli/src/claude/utils/explicitResume.ts b/cli/src/claude/utils/explicitResume.ts new file mode 100644 index 000000000..02c122e2a --- /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 000000000..b941c7eb2 --- /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 000000000..a12d50a2e --- /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/sdkToLogConverter.test.ts b/cli/src/claude/utils/sdkToLogConverter.test.ts index c265fa9d6..89292155e 100644 --- a/cli/src/claude/utils/sdkToLogConverter.test.ts +++ b/cli/src/claude/utils/sdkToLogConverter.test.ts @@ -159,7 +159,20 @@ describe('SDKToLogConverter', () => { }) describe('Result messages', () => { - it('should not convert result messages', () => { + it('should convert result messages into replay-safe subagent meta logs', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate test failure' } + }] + } + } as SDKAssistantMessage) + const sdkMessage: SDKResultMessage = { type: 'result', subtype: 'success', @@ -178,10 +191,120 @@ describe('SDKToLogConverter', () => { const logMessage = converter.convert(sdkMessage) + expect(logMessage).toEqual(expect.objectContaining({ + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-1', + status: 'completed' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'task-1', + title: 'Investigate test failure' + }) + ]) + }) + })) + }) + + it('should fall back to session id as title text when a unique task has no prompt', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: {} + }] + } + } as SDKAssistantMessage) + + const logMessage = converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0.1, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-2' + } as SDKResultMessage) + + expect(logMessage).toEqual(expect.objectContaining({ + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-2', + status: 'completed' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'task-2', + title: 'claude-session-2' + }) + ]) + }) + })) + }) + + it('should not convert result messages when the sidechain key cannot be derived safely', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { prompt: 'Task 1' } + }, + { + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { prompt: 'Task 2' } + } + ] + } + } as SDKAssistantMessage) + + const logMessage = converter.convert({ + type: 'result', + subtype: 'success', + num_turns: 1, + total_cost_usd: 0.1, + duration_ms: 1, + duration_api_ms: 1, + is_error: false, + session_id: 'claude-session-3' + } as SDKResultMessage) + expect(logMessage).toBeNull() }) - it('should not convert error results', () => { + it('should convert error results into replay-safe subagent meta logs', () => { + converter.convert({ + type: 'assistant', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-error', + name: 'Task', + input: { prompt: 'Investigate failure' } + }] + } + } as SDKAssistantMessage) + const sdkMessage: SDKResultMessage = { type: 'result', subtype: 'error_max_turns', @@ -195,8 +318,25 @@ describe('SDKToLogConverter', () => { const logMessage = converter.convert(sdkMessage) - // Error results are not converted to summaries - expect(logMessage).toBeFalsy() + expect(logMessage).toEqual(expect.objectContaining({ + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + meta: expect.objectContaining({ + subagent: expect.arrayContaining([ + expect.objectContaining({ + kind: 'status', + sidechainKey: 'task-error', + status: 'error' + }), + expect.objectContaining({ + kind: 'title', + sidechainKey: 'task-error', + title: 'Investigate failure' + }) + ]) + }) + })) }) }) diff --git a/cli/src/claude/utils/sdkToLogConverter.ts b/cli/src/claude/utils/sdkToLogConverter.ts index fac178cf4..6326dd926 100644 --- a/cli/src/claude/utils/sdkToLogConverter.ts +++ b/cli/src/claude/utils/sdkToLogConverter.ts @@ -13,7 +13,9 @@ import type { SDKResultMessage } from '@/claude/sdk' import type { RawJSONLines } from '@/claude/types' +import type { NormalizedSubagentMeta } from '@/subagents/types' import type { ClaudePermissionMode } from '@hapi/protocol/types' +import { createClaudeSubagentAdapter, type ClaudeSubagentAdapter } from './claudeSubagentAdapter' /** * Context for converting SDK messages to log format @@ -32,6 +34,12 @@ type PermissionResponse = { reason?: string } +type LogMessageWithSubagentMeta = RawJSONLines & { + meta?: { + subagent?: NormalizedSubagentMeta | NormalizedSubagentMeta[] + } +} + /** * Get current git branch for the working directory */ @@ -57,10 +65,12 @@ export class SDKToLogConverter { private context: ConversionContext private responses?: Map private sidechainLastUUID = new Map(); + private readonly subagentAdapter: ClaudeSubagentAdapter constructor( context: Omit, - responses?: Map + responses?: Map, + subagentAdapter: ClaudeSubagentAdapter = createClaudeSubagentAdapter() ) { this.context = { ...context, @@ -69,6 +79,7 @@ export class SDKToLogConverter { parentUuid: null } this.responses = responses + this.subagentAdapter = subagentAdapter } /** @@ -89,17 +100,23 @@ export class SDKToLogConverter { /** * Convert SDK message to log format */ - convert(sdkMessage: SDKMessage): RawJSONLines | null { + convert(sdkMessage: SDKMessage, subagentMeta?: NormalizedSubagentMeta[]): RawJSONLines | null { const uuid = randomUUID() const timestamp = new Date().toISOString() let parentUuid = this.lastUuid; let isSidechain = false; + const resolvedSubagentMeta = subagentMeta ?? this.subagentAdapter.extract(sdkMessage) + const messageMeta = resolvedSubagentMeta.find((meta) => meta.kind === 'message') + const persistedResultMeta = resolvedSubagentMeta.filter((meta) => meta.kind === 'status' || meta.kind === 'title') + const subagentKey = messageMeta?.sidechainKey + ?? (sdkMessage as SDKAssistantMessage).parent_tool_use_id + ?? (sdkMessage as any).parentToolUseId; if (sdkMessage.parent_tool_use_id) { isSidechain = true; parentUuid = this.sidechainLastUUID.get((sdkMessage as any).parent_tool_use_id) ?? null; this.sidechainLastUUID.set((sdkMessage as any).parent_tool_use_id!, uuid); } - const baseFields = { + const baseFields: Record = { parentUuid: parentUuid, isSidechain: isSidechain, userType: 'external' as const, @@ -110,6 +127,14 @@ export class SDKToLogConverter { uuid, timestamp } + if (subagentKey) { + baseFields.meta = { + subagent: { + kind: 'message', + sidechainKey: subagentKey + } + } + } let logMessage: RawJSONLines | null = null @@ -120,7 +145,7 @@ export class SDKToLogConverter { ...baseFields, type: 'user', message: userMsg.message - } + } as any // Check if this is a tool result and add mode if available if (Array.isArray(userMsg.message.content)) { @@ -146,7 +171,7 @@ export class SDKToLogConverter { message: assistantMsg.message, // Assistant messages often have additional fields requestId: (assistantMsg as any).requestId - } + } as any // if (assistantMsg.message.content && Array.isArray(assistantMsg.message.content)) { // for (const content of assistantMsg.message.content) { // if (content.type === 'tool_use' && content.id) { @@ -175,14 +200,30 @@ export class SDKToLogConverter { tools: systemMsg.tools, // Include all other fields ...(systemMsg as any) - } + } as any break } case 'result': { - // Result messages are not converted to log messages - // They're SDK-specific messages that indicate session completion - // Not part of the actual conversation log + if (persistedResultMeta.length === 0) { + break + } + + logMessage = { + type: 'system', + subtype: 'subagent_meta', + isMeta: true, + uuid, + parentUuid, + cwd: this.context.cwd, + sessionId: this.context.sessionId, + version: this.context.version, + gitBranch: this.context.gitBranch, + timestamp, + meta: { + subagent: persistedResultMeta + } + } as LogMessageWithSubagentMeta break } @@ -211,7 +252,7 @@ export class SDKToLogConverter { } } - logMessage = baseLogMessage + logMessage = baseLogMessage as any break } @@ -225,7 +266,7 @@ export class SDKToLogConverter { } // Update last UUID for parent tracking - if (logMessage && logMessage.type !== 'summary') { + if (logMessage && logMessage.type !== 'summary' && logMessage.isMeta !== true) { this.lastUuid = uuid } @@ -263,8 +304,14 @@ export class SDKToLogConverter { content: content }, uuid, - timestamp - } + timestamp, + meta: { + subagent: { + kind: 'message', + sidechainKey: toolUseId + } + } + } as LogMessageWithSubagentMeta } /** diff --git a/cli/src/claude/utils/sessionScanner.test.ts b/cli/src/claude/utils/sessionScanner.test.ts index 709b2152b..cec3e9d88 100644 --- a/cli/src/claude/utils/sessionScanner.test.ts +++ b/cli/src/claude/utils/sessionScanner.test.ts @@ -6,6 +6,36 @@ import { join } from 'node:path' import { tmpdir, homedir } from 'node:os' import { existsSync } from 'node:fs' +function getMessageText(message: RawJSONLines): string | null { + if (message.type === 'summary') { + return message.summary + } + + if (message.type === 'system') { + return null + } + + if (!message.message) { + return null + } + + const content = message.message.content + if (typeof content === 'string') { + return content + } + + if (!Array.isArray(content)) { + return null + } + + return content + .map((block) => block && typeof block === 'object' && 'text' in block && typeof block.text === 'string' + ? block.text + : null) + .filter((value): value is string => value !== null) + .join(' ') +} + describe('sessionScanner', () => { let testDir: string let projectDir: string @@ -37,6 +67,13 @@ describe('sessionScanner', () => { await rm(projectDir, { recursive: true, force: true }) } }) + + async function writeSessionLog(sessionId: string, messages: Array>): Promise { + await writeFile( + join(projectDir, `${sessionId}.jsonl`), + `${messages.map((message) => JSON.stringify(message)).join('\n')}\n` + ) + } it('should process initial session and resumed session correctly', async () => { // TEST SCENARIO: @@ -144,4 +181,427 @@ describe('sessionScanner', () => { expect(content).toContain('readme.md') } }) -}) \ No newline at end of file + + it('links child Claude transcript messages to the parent Task sidechain', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Root cause is a stale retry cache key.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const linkedMessages = collectedMessages.filter((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' + }) + + expect(linkedMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'user', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }), + expect.objectContaining({ + type: 'assistant', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }) + ])) + }) + + it('dedupes replayed linked child transcript messages that were already materialized in the parent transcript', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + }, + { + type: 'user', + uuid: 'materialized-child-user', + parentUuid: null, + sessionId: 'parent-session', + isSidechain: true, + timestamp: '2026-04-04T00:00:01.000Z', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'materialized-child-assistant', + parentUuid: 'materialized-child-user', + sessionId: 'parent-session', + isSidechain: true, + timestamp: '2026-04-04T00:00:02.000Z', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + }, + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Working on it now.' + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:04.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Working on it now.' + }] + } + }, + { + type: 'assistant', + uuid: 'child-assistant-2', + parentUuid: 'child-assistant-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:05.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Found the failing fixture.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const taskMessages = collectedMessages.filter((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' + }) + + expect(taskMessages.filter((message) => getMessageText(message) === 'Investigate flaky test')).toHaveLength(1) + expect(taskMessages.filter((message) => getMessageText(message) === 'Working on it now.')).toHaveLength(1) + expect(taskMessages).toContainEqual(expect.objectContaining({ + type: 'assistant', + sessionId: 'child-session', + isSidechain: true, + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + sidechainKey: 'task-1' + }) + }), + message: expect.objectContaining({ + content: [{ + type: 'text', + text: 'Found the failing fixture.' + }] + }) + })) + }) + + it('does not guess a child link when repeated Task prompts are ambiguous', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call-1', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + }, + { + type: 'assistant', + uuid: 'parent-task-call-2', + parentUuid: 'parent-task-call-1', + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Potentially unrelated child transcript.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + expect(collectedMessages.some((message) => message.sessionId === 'child-session')).toBe(false) + expect(collectedMessages.some((message) => { + const subagent = message.meta?.subagent as Record | undefined + return subagent?.sidechainKey === 'task-1' || subagent?.sidechainKey === 'task-2' + })).toBe(false) + }) + + it('keeps repeated identical linked child transcript messages when they are distinct events', async () => { + await writeSessionLog('parent-session', [ + { + type: 'assistant', + uuid: 'parent-task-call', + parentUuid: null, + sessionId: 'parent-session', + timestamp: '2026-04-04T00:00:00.000Z', + message: { + role: 'assistant', + content: [{ + type: 'tool_use', + id: 'task-1', + name: 'Task', + input: { + prompt: 'Investigate flaky test' + } + }] + } + } + ]) + + await writeSessionLog('child-session', [ + { + type: 'user', + uuid: 'child-user-1', + parentUuid: null, + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:01.000Z', + message: { + role: 'user', + content: 'Investigate flaky test' + } + }, + { + type: 'assistant', + uuid: 'child-assistant-1', + parentUuid: 'child-user-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:02.000Z', + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Still investigating.' + }] + } + }, + { + type: 'assistant', + uuid: 'child-assistant-2', + parentUuid: 'child-assistant-1', + sessionId: 'child-session', + timestamp: '2026-04-04T00:00:03.000Z', + meta: { + subagent: { + kind: 'status', + status: 'running' + } + }, + message: { + role: 'assistant', + content: [{ + type: 'text', + text: 'Still investigating.' + }] + } + } + ]) + + scanner = await createSessionScanner({ + sessionId: 'parent-session', + workingDirectory: testDir, + onMessage: (message) => collectedMessages.push(message), + replayExistingMessages: true + }) + + await scanner.cleanup() + scanner = null + + const repeatedMessages = collectedMessages.filter((message) => ( + message.sessionId === 'child-session' + && getMessageText(message) === 'Still investigating.' + )) + + expect(repeatedMessages).toHaveLength(2) + expect(repeatedMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + uuid: 'child-assistant-1', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'message', + sidechainKey: 'task-1' + }) + }) + }), + expect.objectContaining({ + uuid: 'child-assistant-2', + meta: expect.objectContaining({ + subagent: expect.objectContaining({ + kind: 'status', + status: 'running', + sidechainKey: 'task-1' + }) + }) + }) + ])) + }) +}) diff --git a/cli/src/claude/utils/sessionScanner.ts b/cli/src/claude/utils/sessionScanner.ts index d97dd2230..d6a87a637 100644 --- a/cli/src/claude/utils/sessionScanner.ts +++ b/cli/src/claude/utils/sessionScanner.ts @@ -1,6 +1,6 @@ import { RawJSONLines, RawJSONLinesSchema } from "../types"; import { basename, join } from "node:path"; -import { readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import { logger } from "@/ui/logger"; import { getProjectPath } from "./path"; import { BaseSessionScanner, SessionFileScanEntry, SessionFileScanResult, SessionFileScanStats } from "@/modules/common/session/BaseSessionScanner"; @@ -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(); @@ -41,6 +43,17 @@ export async function createSessionScanner(opts: { export type SessionScanner = ReturnType; +type ClaudeLinkedChild = { + sessionId: string + sidechainKey: string +} + +type ClaudeTaskCandidate = { + sidechainKey: string + prompt: string + sessionId: string | undefined + timestampMs: number +} class ClaudeSessionScanner extends BaseSessionScanner { private readonly projectDir: string; @@ -49,12 +62,23 @@ class ClaudeSessionScanner extends BaseSessionScanner { private readonly pendingSessions = new Set(); private currentSessionId: string | null; private readonly scannedSessions = new Set(); + private readonly replayExistingMessages: boolean; + private readonly linkedChildSessions = new Map(); + private readonly taskCandidateBySidechainKey = new Map(); + private readonly knownEventKeys = new Set(); + private readonly sidechainSyntheticReplayBudget = new Map(); - constructor(opts: { sessionId: string | null; workingDirectory: string; onMessage: (message: RawJSONLines) => void }) { + 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,14 +103,18 @@ class ClaudeSessionScanner extends BaseSessionScanner { } protected async initialize(): Promise { - if (!this.currentSessionId) { + if (!this.currentSessionId || this.replayExistingMessages) { return; } const sessionFile = this.sessionFilePath(this.currentSessionId); const { events, totalLines } = await readSessionLog(sessionFile, 0); + this.captureTaskSidechainCandidates(events.map((entry) => entry.event)); logger.debug(`[SESSION_SCANNER] Marking ${events.length} existing messages as processed from session ${this.currentSessionId}`); - const keys = events.map((entry) => messageKey(entry.event)); - this.seedProcessedKeys(keys); + const keys = events.map((entry) => this.generateEventKey(entry.event, { + filePath: sessionFile, + lineIndex: entry.lineIndex + })); + this.seedKnownProcessedKeys(keys); this.setCursor(sessionFile, totalLines); } @@ -102,6 +130,9 @@ class ClaudeSessionScanner extends BaseSessionScanner { if (this.currentSessionId && !this.pendingSessions.has(this.currentSessionId)) { files.add(this.sessionFilePath(this.currentSessionId)); } + for (const linkedChild of this.linkedChildSessions.values()) { + files.add(this.sessionFilePath(linkedChild.sessionId)); + } for (const watched of this.getWatchedFiles()) { files.add(watched); } @@ -114,25 +145,58 @@ class ClaudeSessionScanner extends BaseSessionScanner { this.scannedSessions.add(sessionId); } const { events, totalLines } = await readSessionLog(filePath, cursor); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; return { - events, + events: linkedChild + ? events.map((entry) => ({ + ...entry, + event: linkChildMessage(entry.event, linkedChild.sidechainKey) + })) + : events, nextCursor: totalLines }; } - protected generateEventKey(event: RawJSONLines): string { - return messageKey(event); + protected generateEventKey(event: RawJSONLines, context: { filePath: string; lineIndex?: number }): string { + const sessionId = sessionIdFromPath(context.filePath); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; + return messageKey(event, linkedChild?.sidechainKey ?? null); } protected async handleFileScan(stats: SessionFileScanStats): Promise { - for (const message of stats.events) { + this.captureTaskSidechainCandidates(stats.events); + this.seedKnownProcessedKeys(stats.entries.map((entry) => this.generateEventKey(entry.event, { + filePath: stats.filePath, + lineIndex: entry.lineIndex + }))); + const sessionId = sessionIdFromPath(stats.filePath); + const linkedChild = sessionId ? this.linkedChildSessions.get(sessionId) : undefined; + const emittedMessages = linkedChild + ? stats.events.filter((message) => !this.consumeSyntheticReplayBudget(message, linkedChild.sidechainKey)) + : stats.events; + + if (!linkedChild) { + for (const message of stats.events) { + const sidechainKey = extractSidechainKey(message); + if (!sidechainKey || message.isSidechain !== true) { + continue; + } + const aliasKey = sidechainSyntheticKey(message, sidechainKey); + if (!aliasKey) { + continue; + } + this.sidechainSyntheticReplayBudget.set(aliasKey, (this.sidechainSyntheticReplayBudget.get(aliasKey) ?? 0) + 1); + } + } + + for (const message of emittedMessages) { const id = message.type === 'summary' ? message.leafUuid : message.uuid; logger.debug(`[SESSION_SCANNER] Sending new message: type=${message.type}, uuid=${id}`); this.onMessage(message); } + await this.linkChildSessionsFromPrompts(); if (stats.parsedCount > 0) { - const sessionId = sessionIdFromPath(stats.filePath) ?? 'unknown'; - logger.debug(`[SESSION_SCANNER] Session ${sessionId}: found=${stats.parsedCount}, skipped=${stats.skippedCount}, sent=${stats.newCount}`); + logger.debug(`[SESSION_SCANNER] Session ${sessionId ?? 'unknown'}: found=${stats.parsedCount}, skipped=${stats.skippedCount}, sent=${emittedMessages.length}`); } } @@ -148,13 +212,191 @@ class ClaudeSessionScanner extends BaseSessionScanner { private sessionFilePath(sessionId: string): string { return join(this.projectDir, `${sessionId}.jsonl`); } + + private seedKnownProcessedKeys(keys: Iterable): void { + for (const key of keys) { + this.knownEventKeys.add(key); + } + this.seedProcessedKeys(keys); + } + + private captureTaskSidechainCandidates(messages: RawJSONLines[]): void { + for (const message of messages) { + if (message.type !== 'assistant' || !message.message || !Array.isArray(message.message.content)) { + continue; + } + + for (const block of message.message.content) { + if (!block || typeof block !== 'object') { + continue; + } + if (block.type !== 'tool_use' || block.name !== 'Task' || typeof block.id !== 'string') { + continue; + } + + const prompt = extractPrompt(block.input); + if (!prompt) { + continue; + } + + if (this.taskCandidateBySidechainKey.has(block.id)) { + continue; + } + + this.taskCandidateBySidechainKey.set(block.id, { + sidechainKey: block.id, + prompt: normalizePrompt(prompt), + sessionId: message.sessionId, + timestampMs: timestampMs(message.timestamp) + }); + } + } + } + + private async linkChildSessionsFromPrompts(): Promise { + if (this.taskCandidateBySidechainKey.size === 0) { + return; + } + + const childCandidateIdsByPrompt = new Map(); + const childCandidatesBySessionId = new Map[] + totalLines: number + }>(); + + const projectEntries = await readdir(this.projectDir, { withFileTypes: true }).catch(() => []); + for (const entry of projectEntries) { + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + continue; + } + + const childSessionId = entry.name.slice(0, -'.jsonl'.length); + if (!childSessionId || childSessionId === this.currentSessionId) { + continue; + } + if (this.pendingSessions.has(childSessionId) || this.finishedSessions.has(childSessionId)) { + continue; + } + if (this.linkedChildSessions.has(childSessionId)) { + continue; + } + + const childFilePath = this.sessionFilePath(childSessionId); + const { events, totalLines } = await readSessionLog(childFilePath, 0); + const prompt = extractFirstUserPrompt(events.map((scanEntry) => scanEntry.event)); + if (!prompt) { + continue; + } + + const taskCandidate = this.getUniqueTaskCandidateForPrompt(normalizePrompt(prompt), timestampFromEntries(events)); + if (!taskCandidate) { + continue; + } + + const candidateIds = childCandidateIdsByPrompt.get(taskCandidate.prompt) ?? []; + candidateIds.push(childSessionId); + childCandidateIdsByPrompt.set(taskCandidate.prompt, candidateIds); + childCandidatesBySessionId.set(childSessionId, { + sessionId: childSessionId, + sidechainKey: taskCandidate.sidechainKey, + filePath: childFilePath, + events, + totalLines + }); + } + + for (const childCandidate of childCandidatesBySessionId.values()) { + const taskCandidate = this.taskCandidateBySidechainKey.get(childCandidate.sidechainKey); + if (!taskCandidate) { + continue; + } + if ((childCandidateIdsByPrompt.get(taskCandidate.prompt) ?? []).length !== 1) { + continue; + } + + const linkedChild: ClaudeLinkedChild = { + sessionId: childCandidate.sessionId, + sidechainKey: childCandidate.sidechainKey + }; + this.linkedChildSessions.set(childCandidate.sessionId, linkedChild); + this.ensureWatcher(childCandidate.filePath); + + const decoratedEntries = childCandidate.events.map((scanEntry) => ({ + ...scanEntry, + event: linkChildMessage(scanEntry.event, childCandidate.sidechainKey) + })); + + const newMessages: RawJSONLines[] = []; + const newKeys: string[] = []; + for (const decoratedEntry of decoratedEntries) { + const key = this.generateEventKey(decoratedEntry.event, { + filePath: childCandidate.filePath, + lineIndex: decoratedEntry.lineIndex + }); + if (this.knownEventKeys.has(key)) { + continue; + } + this.knownEventKeys.add(key); + newKeys.push(key); + if (this.consumeSyntheticReplayBudget(decoratedEntry.event, childCandidate.sidechainKey)) { + continue; + } + newMessages.push(decoratedEntry.event); + } + + for (const message of newMessages) { + const id = message.type === 'summary' ? message.leafUuid : message.uuid; + logger.debug(`[SESSION_SCANNER] Sending linked child message: type=${message.type}, uuid=${id}, sidechain=${childCandidate.sidechainKey}`); + this.onMessage(message); + } + + this.seedProcessedKeys(newKeys); + this.setCursor(childCandidate.filePath, childCandidate.totalLines); + } + } + + private getUniqueTaskCandidateForPrompt(prompt: string, childTimestampMs: number): ClaudeTaskCandidate | null { + const matches = [...this.taskCandidateBySidechainKey.values()].filter((candidate) => ( + candidate.prompt === prompt + && childTimestampMs >= candidate.timestampMs + )); + return matches.length === 1 ? matches[0] : null; + } + + private consumeSyntheticReplayBudget(message: RawJSONLines, sidechainKey: string): boolean { + const aliasKey = sidechainSyntheticKey(message, sidechainKey); + if (!aliasKey) { + return false; + } + const remaining = this.sidechainSyntheticReplayBudget.get(aliasKey) ?? 0; + if (remaining <= 0) { + return false; + } + if (remaining === 1) { + this.sidechainSyntheticReplayBudget.delete(aliasKey); + } else { + this.sidechainSyntheticReplayBudget.set(aliasKey, remaining - 1); + } + return true; + } } // // Helpers // -function messageKey(message: RawJSONLines): string { +function messageKey(message: RawJSONLines, linkedSidechainKey: string | null = null): string { + const sidechainKey = linkedSidechainKey ?? extractSidechainKey(message); + if (sidechainKey) { + const uuidKey = sidechainUuidKey(message, sidechainKey); + if (uuidKey) { + return uuidKey; + } + return sidechainSyntheticKey(message, sidechainKey) ?? `sidechain:${sidechainKey}:missing`; + } if (message.type === 'user') { return message.uuid; } else if (message.type === 'assistant') { @@ -224,3 +466,185 @@ function sessionIdFromPath(filePath: string): string | null { } return base.slice(0, -'.jsonl'.length); } + +function extractPrompt(input: unknown): string | null { + if (typeof input === 'string') { + return input; + } + if (!input || typeof input !== 'object') { + return null; + } + + const record = input as Record; + for (const key of ['prompt', 'title', 'message', 'text', 'content'] as const) { + const value = record[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + + return null; +} + +function extractFirstUserPrompt(messages: RawJSONLines[]): string | null { + for (const message of messages) { + if (message.type !== 'user') { + continue; + } + + const content = message.message.content; + if (typeof content === 'string' && content.length > 0) { + return content; + } + + if (!Array.isArray(content)) { + continue; + } + + const textBlocks = content + .map((block) => block && typeof block === 'object' && 'type' in block && block.type === 'text' && typeof block.text === 'string' + ? block.text + : null) + .filter((value): value is string => value !== null); + + if (textBlocks.length > 0) { + return textBlocks.join(' '); + } + } + + return null; +} + +function timestampFromEntries(entries: SessionFileScanEntry[]): number { + for (const entry of entries) { + const value = timestampMs(entry.event.timestamp); + if (value > 0) { + return value; + } + } + return 0; +} + +function normalizePrompt(prompt: string): string { + return prompt.trim().replace(/\s+/g, ' '); +} + +function linkChildMessage(message: RawJSONLines, sidechainKey: string): RawJSONLines { + return { + ...message, + isSidechain: true, + meta: { + ...(message.meta ?? {}), + subagent: mergeSubagentMeta(message.meta?.subagent, sidechainKey) + } + }; +} + +function mergeSubagentMeta( + subagent: RawJSONLines['meta'] extends { subagent?: infer T } ? T : unknown, + sidechainKey: string +): unknown { + if (Array.isArray(subagent)) { + if (subagent.some((item) => item && typeof item === 'object' && (item as { sidechainKey?: unknown }).sidechainKey === sidechainKey)) { + return subagent; + } + return [{ + kind: 'message', + sidechainKey + }, ...subagent]; + } + + if (subagent && typeof subagent === 'object') { + return { + ...(subagent as Record), + sidechainKey: typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string' + ? (subagent as { sidechainKey: string }).sidechainKey + : sidechainKey + }; + } + + return { + kind: 'message', + sidechainKey + }; +} + +function extractSidechainKey(message: RawJSONLines): string | null { + const subagent = message.meta?.subagent; + if (!subagent) { + return null; + } + if (Array.isArray(subagent)) { + for (const item of subagent) { + if (item && typeof item === 'object' && typeof (item as { sidechainKey?: unknown }).sidechainKey === 'string') { + return (item as { sidechainKey: string }).sidechainKey; + } + } + return null; + } + if (typeof subagent === 'object' && typeof (subagent as { sidechainKey?: unknown }).sidechainKey === 'string') { + return (subagent as { sidechainKey: string }).sidechainKey; + } + return null; +} + +function sidechainMessageFingerprint(message: RawJSONLines): Record { + if (message.type === 'summary') { + return { + type: message.type, + summary: message.summary, + leafUuid: message.leafUuid + }; + } + + if (message.type === 'system') { + return { + type: message.type, + subtype: message.subtype, + isMeta: message.isMeta === true, + error: message.error ?? null, + meta: message.meta?.subagent ?? null + }; + } + + return { + type: message.type, + content: message.message?.content ?? null, + toolUseResult: message.type === 'user' ? message.toolUseResult ?? null : null + }; +} + +function sidechainUuidKey(message: RawJSONLines, sidechainKey: string): string | null { + if ('uuid' in message && typeof message.uuid === 'string' && message.uuid.length > 0) { + return `sidechain:${sidechainKey}:uuid:${message.uuid}`; + } + return null; +} + +function sidechainSyntheticKey(message: RawJSONLines, sidechainKey: string): string | null { + return `sidechain:${sidechainKey}:synthetic:${stableStringify(sidechainMessageFingerprint(message))}`; +} + +function timestampMs(timestamp: string | undefined): number { + if (!timestamp) { + return 0; + } + const value = Date.parse(timestamp); + return Number.isFinite(value) ? value : 0; +} + +function stableStringify(value: unknown): string { + if (value === null || value === undefined) { + return JSON.stringify(value); + } + if (typeof value !== 'object') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map((item) => stableStringify(item)).join(',')}]`; + } + + const record = value as Record; + const keys = Object.keys(record).sort(); + return `{${keys.map((key) => `${JSON.stringify(key)}:${stableStringify(record[key])}`).join(',')}}`; +} diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 7383f54b3..870798d9e 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -12,17 +12,32 @@ import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; -export async function startHappyServer(client: ApiSessionClient) { +export async function startHappyServer( + client: ApiSessionClient, + options?: { + emitSummaryMessage?: boolean; + } +) { + const emitSummaryMessage = options?.emitSummaryMessage ?? true; // Handler that sends title updates via the client const handler = async (title: string) => { logger.debug('[hapiMCP] Changing title to:', title); try { - // Send title as a summary message, similar to title generator - client.sendClaudeSessionMessage({ - type: 'summary', - summary: title, - leafUuid: randomUUID() - }); + if (emitSummaryMessage) { + client.sendClaudeSessionMessage({ + type: 'summary', + summary: title, + leafUuid: randomUUID() + }); + } else { + client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: title, + updatedAt: Date.now() + } + })); + } return { success: true }; } catch (error) { diff --git a/cli/src/codex/codexLocalLauncher.test.ts b/cli/src/codex/codexLocalLauncher.test.ts index 5a72c1792..e6fabb639 100644 --- a/cli/src/codex/codexLocalLauncher.test.ts +++ b/cli/src/codex/codexLocalLauncher.test.ts @@ -1,8 +1,16 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import type { ResolveCodexSessionFileResult } from './utils/resolveCodexSessionFile'; const harness = vi.hoisted(() => ({ launches: [] as Array>, sessionScannerCalls: [] as Array>, + resolverCalls: [] as Array, + resolverResult: { + status: 'found' as const, + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + } as ResolveCodexSessionFileResult, scannerFailureMessage: 'No Codex session found within 120000ms for cwd c:\\workspace\\project; refusing fallback.' })); @@ -22,6 +30,13 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ }) })); +vi.mock('./utils/resolveCodexSessionFile', () => ({ + resolveCodexSessionFile: async (sessionId: string) => { + harness.resolverCalls.push(sessionId); + return harness.resolverResult; + } +})); + vi.mock('./utils/codexSessionScanner', () => ({ createCodexSessionScanner: async (opts: { onSessionMatchFailed?: (message: string) => void; @@ -62,13 +77,18 @@ function createQueueStub() { }; } -function createSessionStub(permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', codexArgs?: string[], path = '/tmp/worktree') { +function createSessionStub( + permissionMode: 'default' | 'read-only' | 'safe-yolo' | 'yolo', + codexArgs?: string[], + path = '/tmp/worktree', + sessionId: string | null = null +) { const sessionEvents: Array<{ type: string; message?: string }> = []; let localLaunchFailure: { message: string; exitReason: 'switch' | 'exit' } | null = null; return { session: { - sessionId: null, + sessionId, path, startedBy: 'terminal' as const, startingMode: 'local' as const, @@ -99,6 +119,55 @@ describe('codexLocalLauncher', () => { afterEach(() => { harness.launches = []; harness.sessionScannerCalls = []; + harness.resolverCalls = []; + harness.resolverResult = { + status: 'found', + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + }; + }); + + it('resolves the resume transcript before creating the scanner', async () => { + const { session } = createSessionStub('default', undefined, '/tmp/worktree', 'session-resume'); + + await codexLocalLauncher(session as never); + + expect(harness.resolverCalls).toEqual(['session-resume']); + expect(harness.sessionScannerCalls).toHaveLength(1); + expect(harness.sessionScannerCalls[0]?.resolvedSessionFile).toEqual({ + status: 'found', + filePath: '/tmp/codex-session-resume.jsonl', + cwd: '/tmp/worktree', + timestamp: 1234567890 + }); + }); + + it('uses an accurate warning when explicit resume resolution failed before launch', async () => { + harness.resolverResult = { + status: 'not_found' + }; + const { session, sessionEvents } = createSessionStub('default', undefined, '/tmp/worktree', 'session-resume'); + + await codexLocalLauncher(session as never); + + const scannerCall = harness.sessionScannerCalls[0] as { onSessionMatchFailed?: (message: string) => void } | undefined; + scannerCall?.onSessionMatchFailed?.('Explicit Codex session resolution failed with status not_found; refusing fallback.'); + + expect(harness.resolverCalls).toEqual(['session-resume']); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Explicit Codex session resolution failed with status not_found; refusing fallback. Keeping local Codex running; remote transcript sync is unavailable for this launch.' + }); + }); + + it('does not call the resolver for fresh launches without a session id', async () => { + const { session } = createSessionStub('default'); + + await codexLocalLauncher(session as never); + + expect(harness.resolverCalls).toEqual([]); + expect(harness.sessionScannerCalls[0]?.resolvedSessionFile).toBeNull(); }); it('rebuilds approval and sandbox args from yolo mode', async () => { diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 9ef714a9c..9c245edd9 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -6,6 +6,7 @@ import { convertCodexEvent } from './utils/codexEventConverter'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; import { stripCodexCliOverrides } from './utils/codexCliOverrides'; import { buildCodexPermissionModeCliArgs } from './utils/permissionModeConfig'; +import { resolveCodexSessionFile } from './utils/resolveCodexSessionFile'; import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { @@ -31,6 +32,9 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch scanner?.onNewSession(sessionId); }; + const resolvedSessionFile = resumeSessionId ? await resolveCodexSessionFile(resumeSessionId) : null; + const isExplicitResumeResolutionFailure = resumeSessionId !== null && resolvedSessionFile?.status !== 'found'; + const launcher = new BaseLocalLauncher({ label: 'codex-local', failureLabel: 'Local Codex process failed', @@ -60,9 +64,12 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch const handleSessionMatchFailed = (message: string) => { logger.warn(`[codex-local]: ${message}`); + const syncStatusMessage = isExplicitResumeResolutionFailure + ? 'remote transcript sync is unavailable for this launch.' + : 'remote transcript sync may be unavailable for this launch.'; session.sendSessionEvent({ type: 'message', - message: `${message} Keeping local Codex running; remote transcript sync may be unavailable for this launch.` + message: `${message} Keeping local Codex running; ${syncStatusMessage}` }); }; @@ -74,6 +81,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch onSessionFound: (sessionId) => { session.onSessionFound(sessionId); }, + resolvedSessionFile, onEvent: (event) => { const converted = convertCodexEvent(event); if (converted?.sessionId) { @@ -81,7 +89,7 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch scanner?.onNewSession(converted.sessionId); } if (converted?.userMessage) { - session.sendUserMessage(converted.userMessage); + session.sendUserMessage(converted.userMessage, converted.userMessageMeta); } if (converted?.message) { session.sendAgentMessage(converted.message); diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 6d1b2c570..043cdf8df 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -1,11 +1,23 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { MessageQueue2 } from '@/utils/MessageQueue2'; -import type { EnhancedMode } from './loop'; +import type { CodexQueuedMessage, EnhancedMode } from './loop'; const harness = vi.hoisted(() => ({ notifications: [] as Array<{ method: string; params: unknown }>, + extraNotifications: [] as Array<{ method: string; params: unknown }>, registerRequestCalls: [] as string[], - initializeCalls: [] as unknown[] + initializeCalls: [] as unknown[], + resolveSessionFileCalls: [] as string[], + sessionScannerCalls: [] as Array>, + replayEvents: [] as unknown[], + startThreadCalls: [] as unknown[], + resumeThreadCalls: [] as unknown[], + startTurnCalls: [] as unknown[], + startThreadError: null as Error | null, + resumeThreadError: null as Error | null, + startTurnError: null as Error | null, + startThreadResponse: { thread: { id: 'thread-started' }, model: 'gpt-5.4' }, + resumeThreadResponse: { thread: { id: 'thread-resumed' }, model: 'gpt-5.4' } })); vi.mock('./codexAppServerClient', () => { @@ -27,15 +39,27 @@ vi.mock('./codexAppServerClient', () => { harness.registerRequestCalls.push(method); } - async startThread(): Promise<{ thread: { id: string }; model: string }> { - return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; + async startThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.startThreadCalls.push(params); + if (harness.startThreadError) { + throw harness.startThreadError; + } + return harness.startThreadResponse; } - async resumeThread(): Promise<{ thread: { id: string }; model: string }> { - return { thread: { id: 'thread-anonymous' }, model: 'gpt-5.4' }; + async resumeThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.resumeThreadCalls.push(params); + if (harness.resumeThreadError) { + throw harness.resumeThreadError; + } + return harness.resumeThreadResponse; } - async startTurn(): Promise<{ turn: Record }> { + async startTurn(params: unknown): Promise<{ turn: Record }> { + harness.startTurnCalls.push(params); + if (harness.startTurnError) { + throw harness.startTurnError; + } const started = { turn: {} }; harness.notifications.push({ method: 'turn/started', params: started }); this.notificationHandler?.('turn/started', started); @@ -44,6 +68,11 @@ vi.mock('./codexAppServerClient', () => { harness.notifications.push({ method: 'turn/completed', params: completed }); this.notificationHandler?.('turn/completed', completed); + for (const notification of harness.extraNotifications) { + harness.notifications.push(notification); + this.notificationHandler?.(notification.method, notification.params); + } + return { turn: {} }; } @@ -66,6 +95,33 @@ vi.mock('./utils/buildHapiMcpBridge', () => ({ }) })); +vi.mock('./utils/resolveCodexSessionFile', () => ({ + resolveCodexSessionFile: async (sessionId: string) => { + harness.resolveSessionFileCalls.push(sessionId); + return { + status: 'found', + filePath: `/tmp/${sessionId}.jsonl`, + cwd: '/tmp/hapi-update', + timestamp: 1234567890 + }; + } +})); + +vi.mock('./utils/codexSessionScanner', () => ({ + createCodexSessionScanner: async (opts: { + onEvent: (event: unknown) => void; + }) => { + harness.sessionScannerCalls.push(opts as Record); + for (const event of harness.replayEvents) { + opts.onEvent(event); + } + return { + cleanup: async () => {}, + onNewSession: () => {} + }; + } +})); + import { codexRemoteLauncher } from './codexRemoteLauncher'; type FakeAgentState = { @@ -80,13 +136,21 @@ function createMode(): EnhancedMode { }; } -function createSessionStub() { - const queue = new MessageQueue2((mode) => JSON.stringify(mode)); - queue.push('hello from launcher test', createMode()); +function createSessionStub(overrides?: { sessionId?: string | null }) { + const queue = new MessageQueue2( + (mode) => JSON.stringify(mode), + null, + (messages) => ({ + text: messages.map((message) => message.text).filter((text) => text.length > 0).join('\n'), + attachments: messages.flatMap((message) => message.attachments ?? []) + }) + ); + queue.push({ text: 'hello from launcher test' }, createMode()); queue.close(); const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; + const userMessages: Array<{ text: string; meta?: unknown }> = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; let currentModel: string | null | undefined; @@ -108,7 +172,9 @@ function createSessionStub() { sendAgentMessage(message: unknown) { codexMessages.push(message); }, - sendUserMessage(_text: string) {}, + sendUserMessage(text: string, meta?: unknown) { + userMessages.push({ text, meta }); + }, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); } @@ -121,7 +187,7 @@ function createSessionStub() { queue, codexArgs: undefined, codexCliOverrides: undefined, - sessionId: null as string | null, + sessionId: overrides?.sessionId ?? null, thinking: false, getPermissionMode() { return 'default' as const; @@ -146,8 +212,8 @@ function createSessionStub() { sendSessionEvent(event: { type: string; [key: string]: unknown }) { client.sendSessionEvent(event); }, - sendUserMessage(text: string) { - client.sendUserMessage(text); + sendUserMessage(text: string, meta?: unknown) { + client.sendUserMessage(text, meta); } }; @@ -155,6 +221,7 @@ function createSessionStub() { session, sessionEvents, codexMessages, + userMessages, thinkingChanges, foundSessionIds, rpcHandlers, @@ -163,27 +230,145 @@ function createSessionStub() { }; } +function createSessionStubWithQueuedMessage( + queuedMessage: CodexQueuedMessage, + overrides?: { sessionId?: string | null } +) { + const stub = createSessionStub(overrides); + stub.session.queue.reset(); + stub.session.queue.push(queuedMessage, createMode()); + stub.session.queue.close(); + return stub; +} + describe('codexRemoteLauncher', () => { afterEach(() => { harness.notifications = []; + harness.extraNotifications = []; harness.registerRequestCalls = []; harness.initializeCalls = []; + harness.resolveSessionFileCalls = []; + harness.sessionScannerCalls = []; + harness.replayEvents = []; + harness.startThreadCalls = []; + harness.resumeThreadCalls = []; + harness.startTurnCalls = []; + harness.startThreadError = null; + harness.resumeThreadError = null; + harness.startTurnError = null; + harness.startThreadResponse = { thread: { id: 'thread-started' }, model: 'gpt-5.4' }; + harness.resumeThreadResponse = { thread: { id: 'thread-resumed' }, model: 'gpt-5.4' }; }); - it('finishes a turn and emits ready when task lifecycle events omit turn_id', async () => { + it('uses resumeThread only for explicit remote resume success', async () => { const { session, sessionEvents, thinkingChanges, foundSessionIds, getModel - } = createSessionStub(); + } = createSessionStub({ sessionId: 'resume-thread-123' }); const exitReason = await codexRemoteLauncher(session as never); expect(exitReason).toBe('exit'); - expect(foundSessionIds).toContain('thread-anonymous'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.resumeThreadCalls[0]).toMatchObject({ threadId: 'resume-thread-123' }); + expect(harness.startThreadCalls).toEqual([]); + expect(foundSessionIds).toContain('thread-resumed'); expect(getModel()).toBe('gpt-5.4'); + expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); + expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); + expect(thinkingChanges).toContain(true); + expect(session.thinking).toBe(false); + }); + + it('replays transcript history during explicit remote resume before live turns', async () => { + harness.replayEvents = [ + { type: 'event_msg', payload: { type: 'user_message', message: 'existing user prompt' } }, + { type: 'event_msg', payload: { type: 'agent_message', message: 'existing assistant reply' } } + ]; + const { + session, + codexMessages, + userMessages + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + await codexRemoteLauncher(session as never); + + expect(harness.resolveSessionFileCalls).toEqual(['resume-thread-123']); + expect(harness.sessionScannerCalls).toHaveLength(1); + expect(userMessages).toContainEqual({ text: 'existing user prompt', meta: undefined }); + expect(codexMessages).toContainEqual(expect.objectContaining({ + type: 'message', + message: 'existing assistant reply' + })); + }); + + it('does not report explicit resume failure when resume succeeds but turn startup fails', async () => { + harness.startTurnError = new Error('turn start failed'); + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.startThreadCalls).toEqual([]); + expect(harness.startTurnCalls).toHaveLength(1); + expect(foundSessionIds).toEqual(['thread-resumed']); + expect(getModel()).toBe('gpt-5.4'); + expect(sessionEvents).toContainEqual({ type: 'message', message: 'Process exited unexpectedly' }); + expect(sessionEvents).not.toContainEqual({ + type: 'message', + message: 'Explicit remote resume failed for thread resume-thread-123' + }); + expect(thinkingChanges).toEqual([false]); + expect(session.thinking).toBe(false); + }); + + it('surfaces explicit remote resume failure without startThread fallback', async () => { + harness.resumeThreadError = new Error('resume failed hard'); + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub({ sessionId: 'resume-thread-123' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.startThreadCalls).toEqual([]); + expect(foundSessionIds).toEqual([]); + expect(getModel()).toBeUndefined(); + expect(sessionEvents).toContainEqual({ + type: 'message', + message: 'Explicit remote resume failed for thread resume-thread-123' + }); + expect(thinkingChanges).toEqual([false]); + expect(session.thinking).toBe(false); + }); + + it('starts a new thread for non-resume sessions and preserves lifecycle signals', async () => { + const { + session, + sessionEvents, + foundSessionIds, + getModel, + thinkingChanges + } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); expect(harness.initializeCalls).toEqual([{ clientInfo: { name: 'hapi-codex-client', @@ -193,9 +378,97 @@ describe('codexRemoteLauncher', () => { experimentalApi: true } }]); + expect(harness.resumeThreadCalls).toEqual([]); + expect(harness.startThreadCalls).toHaveLength(1); + expect(foundSessionIds).toContain('thread-started'); + expect(getModel()).toBe('gpt-5.4'); expect(harness.notifications.map((entry) => entry.method)).toEqual(['turn/started', 'turn/completed']); expect(sessionEvents.filter((event) => event.type === 'ready').length).toBeGreaterThanOrEqual(1); expect(thinkingChanges).toContain(true); expect(session.thinking).toBe(false); }); + + it('passes image attachments to Codex turn/start as localImage inputs', async () => { + const { + session + } = createSessionStubWithQueuedMessage({ + text: 'describe this image', + attachments: [{ + id: 'att-1', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 1234, + path: '/tmp/hapi/photo.jpg' + }] + }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.startTurnCalls).toHaveLength(1); + expect(harness.startTurnCalls[0]).toMatchObject({ + input: [ + { type: 'localImage', path: '/tmp/hapi/photo.jpg' }, + { type: 'text', text: 'describe this image' } + ] + }); + }); + + it('promotes nested parent_tool_call_id from exec command payloads into top-level sidechain metadata', async () => { + harness.extraNotifications = [ + { + method: 'item/completed', + params: { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + } + }, + { + method: 'item/started', + params: { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'ls' + } + } + }, + { + method: 'item/completed', + params: { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + exitCode: 0 + } + } + } + ]; + + const { session, codexMessages } = createSessionStub(); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(codexMessages).toEqual(expect.arrayContaining([ + expect.objectContaining({ + type: 'tool-call', + name: 'CodexBash', + isSidechain: true, + parentToolCallId: 'spawn-1' + }), + expect.objectContaining({ + type: 'tool-call-result', + isSidechain: true, + parentToolCallId: 'spawn-1' + }) + ])); + }); }); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index be648d65d..4d7eae814 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -10,12 +10,15 @@ import { CodexDisplay } from '@/ui/ink/CodexDisplay'; import { buildHapiMcpBridge } from './utils/buildHapiMcpBridge'; import { emitReadyIfIdle } from './utils/emitReadyIfIdle'; import type { CodexSession } from './session'; -import type { EnhancedMode } from './loop'; +import type { CodexQueuedMessage, EnhancedMode } from './loop'; import { hasCodexCliOverrides } from './utils/codexCliOverrides'; 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 { RemoteLauncherBase, type RemoteLauncherDisplayContext, @@ -23,7 +26,7 @@ import { } from '@/modules/common/remote/RemoteLauncherBase'; type HappyServer = Awaited>['server']; -type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +type QueuedMessage = { message: CodexQueuedMessage; mode: EnhancedMode; isolate: boolean; hash: string }; class CodexRemoteLauncher extends RemoteLauncherBase { private readonly session: CodexSession; @@ -117,6 +120,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(); @@ -140,6 +176,21 @@ class CodexRemoteLauncher extends RemoteLauncherBase { 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 => { const resolvedModel = asString(value) ?? undefined; if (!resolvedModel) { @@ -228,6 +279,9 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let clearReadyAfterTurnTimer: (() => void) | null = null; let turnInFlight = false; let allowAnonymousTerminalEvent = false; + let lastRootSessionTitle: string | null = null; + + await replayExplicitResumeTranscript(); const handleCodexEvent = (msg: Record) => { const msgType = asString(msg.type); @@ -244,6 +298,27 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return; } + if (msgType === 'session_title_change') { + const title = asString(msg.title); + if (title) { + lastRootSessionTitle = title; + } + return; + } + + if (msgType === 'subagent_title_change') { + if (lastRootSessionTitle) { + session.client.updateMetadata((metadata) => ({ + ...metadata, + summary: { + text: lastRootSessionTitle!, + updatedAt: Date.now() + } + })); + } + return; + } + if (msgType === 'task_started') { const turnId = eventTurnId; if (turnId) { @@ -350,10 +425,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 }); } } @@ -364,14 +455,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') { @@ -381,13 +480,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') { @@ -396,6 +503,42 @@ 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) { + 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 (msgType === 'patch_apply_begin') { const callId = asString(msg.call_id ?? msg.callId); if (callId) { @@ -404,7 +547,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, @@ -413,7 +556,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') { @@ -431,7 +580,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: { @@ -440,7 +589,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') { @@ -567,6 +722,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }; while (!this.shouldExit) { + let explicitResumeFailureThreadId: string | null = null; logActiveHandles('loop-top'); let message: QueuedMessage | null = pending; pending = null; @@ -588,7 +744,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { break; } - messageBuffer.addMessage(message.message, 'user'); + messageBuffer.addMessage(message.message.text, 'user'); try { if (!hasThread) { @@ -603,24 +759,23 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let threadId: string | null = null; if (resumeCandidate) { - try { - const resumeResponse = await appServerClient.resumeThread({ - threadId: resumeCandidate, - ...threadParams - }, { - signal: this.abortController.signal - }); - const resumeRecord = asRecord(resumeResponse); - const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; - threadId = asString(resumeThread?.id) ?? resumeCandidate; - applyResolvedModel(resumeRecord?.model); - logger.debug(`[Codex] Resumed app-server thread ${threadId}`); - } catch (error) { - logger.warn(`[Codex] Failed to resume app-server thread ${resumeCandidate}, starting new thread`, error); + explicitResumeFailureThreadId = resumeCandidate; + const resumeResponse = await appServerClient.resumeThread({ + threadId: resumeCandidate, + ...threadParams + }, { + signal: this.abortController.signal + }); + const resumeRecord = asRecord(resumeResponse); + const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; + threadId = asString(resumeThread?.id) ?? resumeCandidate; + applyResolvedModel(resumeRecord?.model); + if (!threadId) { + throw new Error('app-server thread/resume did not return thread.id'); } - } - - if (!threadId) { + explicitResumeFailureThreadId = null; + logger.debug(`[Codex] Resumed app-server thread ${threadId}`); + } else { const threadResponse = await appServerClient.startThread(threadParams, { signal: this.abortController.signal }); @@ -633,10 +788,6 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } - if (!threadId) { - throw new Error('app-server resume did not return thread.id'); - } - this.currentThreadId = threadId; session.onSessionFound(threadId); hasThread = true; @@ -651,7 +802,8 @@ class CodexRemoteLauncher extends RemoteLauncherBase { const turnParams = buildTurnStartParams({ threadId: this.currentThreadId, - message: message.message, + message: message.message.text, + attachments: message.message.attachments, cwd: session.path, mode: { ...message.mode, @@ -673,16 +825,25 @@ class CodexRemoteLauncher extends RemoteLauncherBase { allowAnonymousTerminalEvent = true; } } catch (error) { - logger.warn('Error in codex session:', error); const isAbortError = error instanceof Error && error.name === 'AbortError'; turnInFlight = false; allowAnonymousTerminalEvent = false; this.currentTurnId = null; if (isAbortError) { + logger.warn('Error in codex session:', error); messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); + } else if (explicitResumeFailureThreadId) { + logger.warn(`[Codex] Explicit remote resume failed for thread ${explicitResumeFailureThreadId}:`, error); + const failureMessage = `Explicit remote resume failed for thread ${explicitResumeFailureThreadId}`; + messageBuffer.addMessage(failureMessage, 'status'); + session.sendSessionEvent({ type: 'message', message: failureMessage }); + this.currentTurnId = null; + this.currentThreadId = null; + hasThread = false; } else { + logger.warn('Error in codex session:', error); messageBuffer.addMessage('Process exited unexpectedly', 'status'); session.sendSessionEvent({ type: 'message', message: 'Process exited unexpectedly' }); this.currentTurnId = null; diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 013fd07ce..a4e9e1447 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -8,6 +8,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { ReasoningEffort } from './appServerTypes'; import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; +import type { AttachmentMetadata } from '@/api/types'; export type PermissionMode = CodexPermissionMode; @@ -18,12 +19,17 @@ export interface EnhancedMode { modelReasoningEffort?: ReasoningEffort; } +export interface CodexQueuedMessage { + text: string; + attachments?: AttachmentMetadata[]; +} + interface LoopOptions { path: string; startingMode?: 'local' | 'remote'; startedBy?: 'runner' | 'terminal'; onModeChange: (mode: 'local' | 'remote') => void; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; session: ApiSessionClient; api: ApiClient; codexArgs?: string[]; diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index d94286a14..9f5a92869 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -10,9 +10,9 @@ import { bootstrapSession } from '@/agent/sessionFactory'; import { createModeChangeHandler, createRunnerLifecycle, setControlledByUser } from '@/agent/runnerLifecycle'; import { isPermissionModeAllowedForFlavor } from '@hapi/protocol'; import { CodexCollaborationModeSchema, PermissionModeSchema } from '@hapi/protocol/schemas'; -import { formatMessageWithAttachments } from '@/utils/attachmentFormatter'; import { getInvokedCwd } from '@/utils/invokedCwd'; import type { ReasoningEffort } from './appServerTypes'; +import type { CodexQueuedMessage } from './loop'; export { emitReadyIfIdle } from './utils/emitReadyIfIdle'; @@ -44,12 +44,19 @@ export async function runCodex(opts: { setControlledByUser(session, startingMode); - const messageQueue = new MessageQueue2((mode) => hashObject({ - permissionMode: mode.permissionMode, - model: mode.model, - modelReasoningEffort: mode.modelReasoningEffort, - collaborationMode: mode.collaborationMode - })); + const messageQueue = new MessageQueue2( + (mode) => hashObject({ + permissionMode: mode.permissionMode, + model: mode.model, + modelReasoningEffort: mode.modelReasoningEffort, + collaborationMode: mode.collaborationMode + }), + null, + (messages) => ({ + text: messages.map((message) => message.text).filter((text) => text.length > 0).join('\n'), + attachments: messages.flatMap((message) => message.attachments ?? []) + }) + ); const codexCliOverrides = parseCodexCliOverrides(opts.codexArgs); const sessionWrapperRef: { current: CodexSession | null } = { current: null }; @@ -114,8 +121,10 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode }; - const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); - messageQueue.push(formattedText, enhancedMode); + messageQueue.push({ + text: message.content.text, + attachments: message.content.attachments + }, enhancedMode); }); const formatFailureReason = (message: string): string => { diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 5fb1c3ea7..a314f4037 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -1,7 +1,7 @@ import { ApiClient, ApiSessionClient } from '@/lib'; import { MessageQueue2 } from '@/utils/MessageQueue2'; import { AgentSessionBase } from '@/agent/sessionBase'; -import type { EnhancedMode, PermissionMode } from './loop'; +import type { CodexQueuedMessage, EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { SessionModel } from '@/api/types'; @@ -11,7 +11,7 @@ type LocalLaunchFailure = { exitReason: LocalLaunchExitReason; }; -export class CodexSession extends AgentSessionBase { +export class CodexSession extends AgentSessionBase { readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; readonly startedBy: 'runner' | 'terminal'; @@ -24,7 +24,7 @@ export class CodexSession extends AgentSessionBase { path: string; logPath: string; sessionId: string | null; - messageQueue: MessageQueue2; + messageQueue: MessageQueue2; onModeChange: (mode: 'local' | 'remote') => void; mode?: 'local' | 'remote'; startedBy: 'runner' | 'terminal'; @@ -84,8 +84,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.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index 0951b4133..5fa7b707b 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -126,6 +126,31 @@ describe('appServerConfig', () => { expect(params.model).toBeUndefined(); }); + it('maps image attachments to localImage inputs for Codex turns', () => { + const params = buildTurnStartParams({ + threadId: 'thread-1', + message: 'describe this image', + cwd: '/workspace/project', + mode: { + permissionMode: 'default', + model: 'o3', + collaborationMode: 'default' + }, + attachments: [{ + id: 'att-1', + filename: 'photo.jpg', + mimeType: 'image/jpeg', + size: 1234, + path: '/tmp/hapi/photo.jpg' + }] as any + } as any); + + expect(params.input).toEqual([ + { type: 'localImage', path: '/tmp/hapi/photo.jpg' }, + { type: 'text', text: 'describe this image' } + ]); + }); + it('puts collaboration mode in turn params with model settings', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 12565909f..9469cfe9f 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -2,12 +2,14 @@ import type { EnhancedMode } from '../loop'; import type { CodexCliOverrides } from './codexCliOverrides'; import type { McpServersConfig } from './buildHapiMcpBridge'; import { codexSystemPrompt } from './systemPrompt'; +import type { AttachmentMetadata } from '@/api/types'; import type { ApprovalPolicy, SandboxMode, SandboxPolicy, ThreadStartParams, - TurnStartParams + TurnStartParams, + UserInput } from '../appServerTypes'; import { resolveCodexPermissionModeConfig } from './permissionModeConfig'; @@ -108,6 +110,7 @@ export function buildThreadStartParams(args: { export function buildTurnStartParams(args: { threadId: string; message: string; + attachments?: AttachmentMetadata[]; cwd: string; mode?: EnhancedMode; cliOverrides?: CodexCliOverrides; @@ -119,10 +122,26 @@ export function buildTurnStartParams(args: { model?: string; }; }): TurnStartParams { + const inputs: UserInput[] = []; + const fileAttachments: string[] = []; + + for (const attachment of args.attachments ?? []) { + if (attachment.mimeType.startsWith('image/')) { + inputs.push({ type: 'localImage', path: attachment.path }); + continue; + } + fileAttachments.push(`@${attachment.path}`); + } + + const textParts = [fileAttachments.join(' '), args.message].filter((part) => part.length > 0); + if (textParts.length > 0) { + inputs.push({ type: 'text', text: textParts.join('\n\n') }); + } + const params: TurnStartParams = { threadId: args.threadId, cwd: args.cwd, - input: [{ type: 'text', text: args.message }] + input: inputs }; const allowCliOverrides = args.mode?.permissionMode === 'default'; diff --git a/cli/src/codex/utils/appServerEventConverter.test.ts b/cli/src/codex/utils/appServerEventConverter.test.ts index 272769260..5740669f9 100644 --- a/cli/src/codex/utils/appServerEventConverter.test.ts +++ b/cli/src/codex/utils/appServerEventConverter.test.ts @@ -85,6 +85,320 @@ describe('AppServerEventConverter', () => { }]); }); + it('maps live collab spawn/wait calls and annotates child-thread messages', () => { + const converter = new AppServerEventConverter(); + + const spawnStarted = converter.handleNotification('item/started', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + prompt: 'delegate work', + model: 'gpt-5.4-mini', + reasoningEffort: 'low' + } + }); + expect(spawnStarted).toEqual([{ + type: 'tool_call', + call_id: 'spawn-1', + name: 'CodexSpawnAgent', + input: { + message: 'delegate work', + model: 'gpt-5.4-mini', + reasoningEffort: 'low' + } + }]); + + const spawnCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'pendingInit' + } + } + } + }); + expect(spawnCompleted).toEqual([{ + type: 'tool_call_result', + call_id: 'spawn-1', + output: { + agent_id: 'child-thread-1', + agent_ids: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'pendingInit' + } + } + } + }]); + + const childUser = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-user-1', + type: 'userMessage', + content: [{ type: 'text', text: 'child prompt' }] + } + }); + expect(childUser).toEqual([{ + type: 'user_message', + message: 'child prompt', + parent_tool_call_id: 'spawn-1' + }]); + + const childAgent = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-agent-1', + type: 'agentMessage', + content: [{ type: 'text', text: 'child answer' }] + } + }); + expect(childAgent).toEqual([{ + type: 'agent_message', + message: 'child answer', + parent_tool_call_id: 'spawn-1' + }]); + + const waitStarted = converter.handleNotification('item/started', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'] + } + }); + expect(waitStarted).toEqual([{ + type: 'tool_call', + call_id: 'wait-1', + name: 'CodexWaitAgent', + input: { + targets: ['child-thread-1'] + } + }]); + + const waitCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'completed', + message: 'done' + } + } + } + }); + expect(waitCompleted).toEqual([ + { + type: 'agent_message', + message: 'done', + parent_tool_call_id: 'spawn-1' + }, + { + type: 'tool_call_result', + call_id: 'wait-1', + output: { + statuses: { + 'child-thread-1': { + status: 'completed', + message: 'done' + } + } + } + } + ]); + }); + + it('reads child thread id from the item payload when top-level threadId is missing', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const childAgent = converter.handleNotification('item/completed', { + item: { + id: 'child-agent-1', + type: 'agentMessage', + threadId: 'child-thread-1', + content: [{ type: 'text', text: 'hello from child' }] + } + }); + + expect(childAgent).toEqual([{ + type: 'agent_message', + message: 'hello from child', + parent_tool_call_id: 'spawn-1' + }]); + }); + + it('backfills missing child agent messages from wait results without duplicating later completions', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const waitCompleted = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'wait-1', + type: 'collabAgentToolCall', + tool: 'wait', + receiverThreadIds: ['child-thread-1'], + agentsStates: { + 'child-thread-1': { + status: 'completed', + message: 'fallback child answer' + } + } + } + }); + + expect(waitCompleted[0]).toEqual({ + type: 'agent_message', + message: 'fallback child answer', + parent_tool_call_id: 'spawn-1' + }); + + const childCompleted = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'child-agent-1', + type: 'agentMessage', + content: [{ type: 'text', text: 'fallback child answer' }] + } + }); + + expect(childCompleted).toEqual([]); + }); + + it('annotates child command execution events with the parent spawn id', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const started = converter.handleNotification('item/started', { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + command: 'ls' + } + }); + expect(started).toEqual([{ + type: 'exec_command_begin', + call_id: 'cmd-1', + command: 'ls', + parent_tool_call_id: 'spawn-1' + }]); + + converter.handleNotification('item/commandExecution/outputDelta', { + threadId: 'child-thread-1', + itemId: 'cmd-1', + delta: 'child output' + }); + + const completed = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'cmd-1', + type: 'commandExecution', + exitCode: 0 + } + }); + expect(completed).toEqual([{ + type: 'exec_command_end', + call_id: 'cmd-1', + command: 'ls', + output: 'child output', + exit_code: 0, + parent_tool_call_id: 'spawn-1' + }]); + }); + + it('ignores child-thread hapi change_title tool calls in live mode', () => { + const converter = new AppServerEventConverter(); + + converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'spawn-1', + type: 'collabAgentToolCall', + tool: 'spawnAgent', + receiverThreadIds: ['child-thread-1'] + } + }); + + const events = converter.handleNotification('item/completed', { + threadId: 'child-thread-1', + item: { + id: 'title-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'child title' } + } + }); + + expect(events).toEqual([{ + type: 'subagent_title_change', + title: 'child title', + parent_tool_call_id: 'spawn-1' + }]); + }); + + it('emits root session title changes for parent-thread hapi change_title calls', () => { + const converter = new AppServerEventConverter(); + + const events = converter.handleNotification('item/completed', { + threadId: 'parent-thread', + item: { + id: 'title-1', + type: 'mcpToolCall', + server: 'hapi', + tool: 'change_title', + arguments: { title: 'root title' } + } + }); + + expect(events).toEqual([{ + type: 'session_title_change', + title: 'root title' + }]); + }); + 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 08a957630..f529b1e18 100644 --- a/cli/src/codex/utils/appServerEventConverter.ts +++ b/cli/src/codex/utils/appServerEventConverter.ts @@ -135,6 +135,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); @@ -165,6 +186,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 +203,7 @@ export class AppServerEventConverter { event.error = error; } } - return [event]; + return [this.addSidechainMeta(event, threadId)]; } if (msgType === 'agent_message_delta' || msgType === 'agent_message_content_delta') { @@ -384,11 +406,149 @@ 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) + : []; + 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, + 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 +556,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 +581,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 +601,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 +616,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 +625,7 @@ export class AppServerEventConverter { ...(error ? { error } : {}), ...(exitCode !== null ? { exit_code: exitCode } : {}), ...(status ? { status } : {}) - }); + }, threadId)); this.commandMeta.delete(itemId); this.commandOutputBuffers.delete(itemId); @@ -475,11 +644,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 +657,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 +689,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/buildHapiMcpBridge.ts b/cli/src/codex/utils/buildHapiMcpBridge.ts index 8520ae22e..b3bb96cc0 100644 --- a/cli/src/codex/utils/buildHapiMcpBridge.ts +++ b/cli/src/codex/utils/buildHapiMcpBridge.ts @@ -43,7 +43,9 @@ export interface HapiMcpBridge { * used by both local and remote launchers. */ export async function buildHapiMcpBridge(client: ApiSessionClient): Promise { - const happyServer = await startHappyServer(client); + const happyServer = await startHappyServer(client, { + emitSummaryMessage: false + }); const bridgeCommand = getHappyCliCommand(['mcp', '--url', happyServer.url]); return { diff --git a/cli/src/codex/utils/codexEventConverter.test.ts b/cli/src/codex/utils/codexEventConverter.test.ts index 3abf77763..f0ea2c375 100644 --- a/cli/src/codex/utils/codexEventConverter.test.ts +++ b/cli/src/codex/utils/codexEventConverter.test.ts @@ -75,6 +75,35 @@ describe('convertCodexEvent', () => { }); }); + it.each([ + ['exec_command', 'CodexBash'], + ['write_stdin', 'CodexWriteStdin'], + ['spawn_agent', 'CodexSpawnAgent'], + ['wait_agent', 'CodexWaitAgent'], + ['send_input', 'CodexSendInput'], + ['close_agent', 'CodexCloseAgent'], + ['update_plan', 'update_plan'], + ['mcp__hapi__change_title', 'mcp__hapi__change_title'], + ['unknown_tool', 'unknown_tool'] + ])('normalizes function_call tool name %s -> %s', (inputName, expectedName) => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: inputName, + call_id: 'call-1', + arguments: '{"foo":"bar"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: expectedName, + callId: 'call-1', + input: { foo: 'bar' } + }); + }); + it('converts function_call_output items', () => { const result = convertCodexEvent({ type: 'response_item', @@ -91,4 +120,125 @@ describe('convertCodexEvent', () => { output: { ok: true } }); }); + + it('normalizes Codex spawn_agent events into subagent metadata', () => { + const result = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: 'spawn-1', + arguments: '{"message":"delegate search task"}' + } + }); + + expect(result?.message).toMatchObject({ + type: 'tool-call', + name: 'CodexSpawnAgent', + callId: 'spawn-1', + meta: expect.objectContaining({ + subagent: { + kind: 'spawn', + sidechainKey: 'spawn-1', + prompt: 'delegate search task' + } + }) + }); + }); + + it('preserves sidechain metadata on user and agent/tool messages', () => { + const userResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'user_message', + message: 'child prompt' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(userResult).toEqual({ + userMessage: 'child prompt', + userMessageMeta: { + isSidechain: true, + sidechainKey: 'spawn-call-1' + } + }); + + const agentResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'agent_message', + message: 'child answer' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(agentResult?.message).toMatchObject({ + type: 'message', + message: 'child answer', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const reasoningResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'agent_reasoning', + text: 'thinking' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(reasoningResult?.message).toMatchObject({ + type: 'reasoning', + message: 'thinking', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const tokenCountResult = convertCodexEvent({ + type: 'event_msg', + payload: { + type: 'token_count', + info: { input_tokens: 1 } + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(tokenCountResult?.message).toMatchObject({ + type: 'token_count', + info: { input_tokens: 1 }, + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + + const toolResult = convertCodexEvent({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: 'call-1', + arguments: '{}' + }, + hapiSidechain: { + parentToolCallId: 'spawn-call-1' + } + }); + + expect(toolResult?.message).toMatchObject({ + type: 'tool-call', + name: 'CodexSpawnAgent', + callId: 'call-1', + isSidechain: true, + parentToolCallId: 'spawn-call-1' + }); + }); }); diff --git a/cli/src/codex/utils/codexEventConverter.ts b/cli/src/codex/utils/codexEventConverter.ts index 24ecfd241..85f6bad35 100644 --- a/cli/src/codex/utils/codexEventConverter.ts +++ b/cli/src/codex/utils/codexEventConverter.ts @@ -1,47 +1,82 @@ import { randomUUID } from 'node:crypto'; import { z } from 'zod'; import { logger } from '@/ui/logger'; +import { createSpawnMeta } from '@/subagents/normalize'; +import type { NormalizedSubagentMeta } from '@/subagents/types'; 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?: NormalizedSubagentMeta; +}; + 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 +107,57 @@ function parseArguments(value: unknown): unknown { return value; } +function extractSpawnPrompt(input: unknown): string | undefined { + if (!input || typeof input !== 'object') { + return undefined; + } + + const record = input as Record; + return asString(record.message) + ?? asString(record.prompt) + ?? asString(record.text) + ?? asString(record.content) + ?? undefined; +} + +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 + }; +} + +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', @@ -99,6 +185,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 +212,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 +230,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'message', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -150,11 +244,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning', message, id: randomUUID() - } + }, sidechainMeta) }; } @@ -164,10 +258,10 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'reasoning-delta', delta - } + }, sidechainMeta) }; } @@ -177,11 +271,11 @@ export function convertCodexEvent(rawEvent: unknown): CodexConversionResult | nu return null; } return { - message: { + message: applySidechainMeta({ type: 'token_count', info, id: randomUUID() - } + }, sidechainMeta) }; } @@ -200,14 +294,25 @@ 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: createSpawnMeta({ + sidechainKey: callId, + 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.test.ts b/cli/src/codex/utils/codexSessionScanner.test.ts index cca21df38..61d6dcb64 100644 --- a/cli/src/codex/utils/codexSessionScanner.test.ts +++ b/cli/src/codex/utils/codexSessionScanner.test.ts @@ -4,6 +4,7 @@ import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { existsSync } from 'node:fs'; import { createCodexSessionScanner } from './codexSessionScanner'; +import type { ResolveCodexSessionFileResult } from './resolveCodexSessionFile'; import type { CodexSessionEvent } from './codexEventConverter'; const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms)); @@ -74,6 +75,239 @@ describe('codexSessionScanner', () => { expect(events[0].type).toBe('response_item'); }); + it('enriches child transcript events and trims the copied parent prefix', async () => { + const parentSessionId = 'parent-session-1'; + const parentToolCallId = 'spawn-call-1'; + const childSessionId = 'child-session-1'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: parentFile, + cwd: '/data/github/happy/hapi', + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + parentFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'spawn_agent', call_id: parentToolCallId, arguments: '{"message":"delegate"}' } + }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_meta'); + expect((events[0].payload as Record).id).toBe(parentSessionId); + expect(events[1].type).toBe('response_item'); + expect((events[1].payload as Record).call_id).toBe(parentToolCallId); + + await appendFile( + parentFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }), + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'wait_agent', call_id: 'wait-call-1', arguments: JSON.stringify({ targets: [childSessionId], timeout_ms: 30000 }) } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'wait-call-1', + output: { status: { [childSessionId]: { completed: 'done' } } } + } + }), + JSON.stringify({ + type: 'event_msg', + payload: { type: 'user_message', message: 'child done' } + }) + ].join('\n') + '\n' + ); + + await wait(2300); + expect(events.some((event) => event.type === 'response_item' && (event.payload as Record).call_id === parentToolCallId)).toBe(true); + expect(events.some((event) => event.type === 'response_item' && (event.payload as Record).call_id === 'wait-call-1')).toBe(true); + expect(events.some((event) => event.type === 'event_msg' && (event.payload as Record).message === 'child done')).toBe(true); + + for (const event of events) { + expect((event as Record).hapiSidechain).toBeUndefined(); + } + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'copied parent prompt' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + expect(events.find((event) => (event.payload as Record)?.message === 'copied parent prompt')).toBeUndefined(); + + await appendFile( + childFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-call-1', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'child answer' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + const copiedPrefixEvent = events.find((event) => (event.payload as Record)?.message === 'copied parent prompt'); + expect(copiedPrefixEvent).toBeUndefined(); + + const childUserEvent = events.find((event) => (event.payload as Record)?.message === 'child prompt'); + expect(childUserEvent).toBeDefined(); + expect((childUserEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childAnswerEvent = events.find((event) => (event.payload as Record)?.message === 'child answer'); + expect(childAnswerEvent).toBeDefined(); + expect((childAnswerEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childSessionMetaEvent = events.find((event) => event.type === 'session_meta' && (event.payload as Record).id === childSessionId); + expect(childSessionMetaEvent).toBeUndefined(); + + const parentWaitEvent = events.find((event) => event.type === 'response_item' && (event.payload as Record).call_id === 'wait-call-1'); + expect((parentWaitEvent as Record).hapiSidechain).toBeUndefined(); + }, 10000); + + it('links child Codex lifecycle entries using normalized sidechain metadata', async () => { + const parentSessionId = 'parent-session-2'; + const parentToolCallId = 'spawn-call-2'; + const childSessionId = 'child-session-2'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: parentFile, + cwd: '/data/github/happy/hapi', + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + parentFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call', + name: 'spawn_agent', + call_id: parentToolCallId, + arguments: '{"message":"delegate"}' + } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-call-2', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-2' } }), + JSON.stringify({ + type: 'subagent_title_change', + title: 'child title' + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_complete', turn_id: 'child-turn-2' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt' } }) + ].join('\n') + '\n' + ); + + await wait(2300); + + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'task_started', + turn_id: 'child-turn-2' + }), + hapiSidechain: { parentToolCallId } + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'task_complete', + turn_id: 'child-turn-2' + }), + hapiSidechain: { parentToolCallId } + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'subagent_title_change', + title: 'child title', + hapiSidechain: { parentToolCallId } + }) + ); + expect(events).toContainEqual( + expect.objectContaining({ + type: 'event_msg', + payload: expect.objectContaining({ + type: 'user_message', + message: 'child prompt' + }), + hapiSidechain: { parentToolCallId } + }) + ); + }, 10000); + it('limits session scan to dates within the start window', async () => { const referenceTimestampMs = Date.parse('2025-12-22T00:00:00.000Z'); const windowMs = 2 * 60 * 1000; @@ -149,6 +383,198 @@ describe('codexSessionScanner', () => { expect(events).toHaveLength(0); }); + it('explicit resume scans only the resolved file and ignores stray matching cwd files', async () => { + const targetCwd = '/data/github/happy/hapi'; + const resolvedSessionId = 'session-explicit-resolved'; + const straySessionId = 'session-explicit-stray'; + const resolvedFile = join(sessionsDir, `codex-${resolvedSessionId}.jsonl`); + const strayFile = join(sessionsDir, `codex-${straySessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: resolvedSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'resolved-initial' } }) + ].join('\n') + '\n' + ); + await writeFile( + strayFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: straySessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'stray-initial' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: resolvedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(2); + expect(events[0].type).toBe('session_meta'); + expect(events[1].type).toBe('event_msg'); + expect((events[1].payload as Record).message).toBe('resolved-initial'); + + await appendFile( + strayFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-stray', arguments: '{}' } + }) + '\n' + ); + await appendFile( + resolvedFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-resolved', arguments: '{}' } + }) + '\n' + ); + + await wait(2300); + expect(events).toHaveLength(3); + expect(events[2].type).toBe('response_item'); + expect((events[2].payload as Record).call_id).toBe('call-resolved'); + }); + + it('explicit resume replays a leading lineage block for the requested session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const requestedSessionId = 'session-explicit-current'; + const ancestorSessionId = 'session-explicit-ancestor'; + const resolvedFile = join(sessionsDir, `codex-${requestedSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T00:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: requestedSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'session_meta', payload: { id: ancestorSessionId, cwd: targetCwd, timestamp: '2025-12-21T23:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'current-segment-message' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', name: 'Tool', call_id: 'call-current', arguments: '{}' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: requestedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(3); + expect(events.map((event) => event.type)).toEqual(['session_meta', 'event_msg', 'response_item']); + expect((events[0].payload as Record).id).toBe(requestedSessionId); + expect((events[1].payload as Record).message).toBe('current-segment-message'); + expect((events[2].payload as Record).call_id).toBe('call-current'); + }); + + it('explicit resume emits only the matching segment when a later segment starts a new session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const firstSessionId = 'session-explicit-first'; + const secondSessionId = 'session-explicit-second'; + const resolvedFile = join(sessionsDir, `codex-${secondSessionId}.jsonl`); + const resolvedResult: ResolveCodexSessionFileResult = { + status: 'found', + filePath: resolvedFile, + cwd: targetCwd, + timestamp: Date.parse('2025-12-22T01:00:00.000Z') + }; + + await writeFile( + resolvedFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: firstSessionId, cwd: targetCwd, timestamp: '2025-12-22T00:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'first-segment-message' } }), + JSON.stringify({ type: 'session_meta', payload: { id: secondSessionId, cwd: targetCwd, timestamp: '2025-12-22T01:00:00.000Z' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'second-segment-message' } }), + JSON.stringify({ type: 'response_item', payload: { type: 'function_call', name: 'Tool', call_id: 'call-second', arguments: '{}' } }) + ].join('\n') + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: secondSessionId, + cwd: targetCwd, + resolvedSessionFile: resolvedResult, + onEvent: (event) => events.push(event) + }); + + await wait(200); + expect(events).toHaveLength(3); + expect(events.map((event) => event.type)).toEqual(['session_meta', 'event_msg', 'response_item']); + expect((events[0].payload as Record).id).toBe(secondSessionId); + expect((events[1].payload as Record).message).toBe('second-segment-message'); + expect((events[2].payload as Record).call_id).toBe('call-second'); + }); + + it('explicit resume failure does not adopt another session', async () => { + const targetCwd = '/data/github/happy/hapi'; + const requestedSessionId = 'session-explicit-missing'; + const fallbackSessionId = 'session-fallback-candidate'; + const fallbackFile = join(sessionsDir, `codex-${fallbackSessionId}.jsonl`); + const resolverFailureResult: ResolveCodexSessionFileResult = { + status: 'not_found' + }; + + await writeFile( + fallbackFile, + JSON.stringify({ + type: 'session_meta', + payload: { + id: fallbackSessionId, + cwd: targetCwd, + timestamp: new Date(Date.now() - 10 * 60 * 1000).toISOString() + } + }) + '\n' + ); + + let failureMessage: string | null = null; + let matchedSessionId: string | null = null; + scanner = await createCodexSessionScanner({ + sessionId: requestedSessionId, + cwd: targetCwd, + resolvedSessionFile: resolverFailureResult, + onEvent: (event) => events.push(event), + onSessionFound: (sessionId) => { + matchedSessionId = sessionId; + }, + onSessionMatchFailed: (message) => { + failureMessage = message; + } + }); + + await wait(200); + expect(failureMessage).not.toBeNull(); + expect(matchedSessionId).toBeNull(); + expect(events).toHaveLength(0); + + await appendFile( + fallbackFile, + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'Tool', call_id: 'call-fallback', arguments: '{}' } + }) + '\n' + ); + + await wait(2300); + expect(failureMessage).not.toBeNull(); + expect(matchedSessionId).toBeNull(); + expect(events).toHaveLength(0); + }); + it('adopts a reused older session file when fresh matching activity appears after startup', async () => { const reusedSessionId = 'session-reused-old-file'; const targetCwd = '/data/github/happy/hapi'; @@ -203,6 +629,83 @@ describe('codexSessionScanner', () => { expect(events[0].type).toBe('response_item'); }); + it('links child transcripts in non-explicit live session path', async () => { + const parentSessionId = 'parent-live-1'; + const parentToolCallId = 'spawn-live-1'; + const childSessionId = 'child-live-1'; + const parentFile = join(sessionsDir, `codex-${parentSessionId}.jsonl`); + const childFile = join(sessionsDir, `codex-${childSessionId}.jsonl`); + + await writeFile( + parentFile, + JSON.stringify({ type: 'session_meta', payload: { id: parentSessionId } }) + '\n' + ); + + scanner = await createCodexSessionScanner({ + sessionId: parentSessionId, + onEvent: (event) => events.push(event) + }); + + await wait(200); + + await appendFile( + parentFile, + [ + JSON.stringify({ + type: 'response_item', + payload: { type: 'function_call', name: 'spawn_agent', call_id: parentToolCallId, arguments: '{"message":"delegate"}' } + }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: parentToolCallId, + output: JSON.stringify({ agent_id: childSessionId, nickname: 'child' }) + } + }) + ].join('\n') + '\n' + ); + + await wait(2500); + + await writeFile( + childFile, + [ + JSON.stringify({ type: 'session_meta', payload: { id: childSessionId } }), + JSON.stringify({ + type: 'response_item', + payload: { + type: 'function_call_output', + call_id: 'bootstrap-1', + output: 'You are the newly spawned agent. The prior conversation history was forked from your parent agent. Treat the next user message as your new task, and use the forked history only as background context.' + } + }), + JSON.stringify({ type: 'event_msg', payload: { type: 'task_started', turn_id: 'child-turn-1' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'user_message', message: 'child prompt live' } }), + JSON.stringify({ type: 'event_msg', payload: { type: 'agent_message', message: 'child answer live' } }) + ].join('\n') + '\n' + ); + + await wait(4500); + + const childUserEvent = events.find((event) => (event.payload as Record)?.message === 'child prompt live'); + expect(childUserEvent).toBeDefined(); + expect((childUserEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + const childAnswerEvent = events.find((event) => (event.payload as Record)?.message === 'child answer live'); + expect(childAnswerEvent).toBeDefined(); + expect((childAnswerEvent as Record).hapiSidechain).toEqual({ parentToolCallId }); + + // Parent spawn call should not have sidechain metadata + const spawnCall = events.find((event) => + event.type === 'response_item' + && (event.payload as Record)?.type === 'function_call' + && (event.payload as Record)?.call_id === parentToolCallId + ); + expect(spawnCall).toBeDefined(); + expect((spawnCall as Record).hapiSidechain).toBeUndefined(); + }, 15000); + it('does not adopt a reused session when first fresh matching activity is ambiguous', async () => { const targetCwd = '/data/github/happy/hapi'; const startupTimestampMs = Date.now(); diff --git a/cli/src/codex/utils/codexSessionScanner.ts b/cli/src/codex/utils/codexSessionScanner.ts index fd8f45b0a..3a25f8468 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 000000000..1c5ce1f9c --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.test.ts @@ -0,0 +1,246 @@ +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(); + }); +}); diff --git a/cli/src/codex/utils/listImportableCodexSessions.ts b/cli/src/codex/utils/listImportableCodexSessions.ts new file mode 100644 index 000000000..5b0e3d3a1 --- /dev/null +++ b/cli/src/codex/utils/listImportableCodexSessions.ts @@ -0,0 +1,395 @@ +import { homedir } from 'node:os'; +import { basename, join } from 'node:path'; +import { readdir, readFile } from 'node:fs/promises'; +import type { ImportableCodexSessionSummary } from '@hapi/protocol/rpcTypes'; + +export type ListImportableCodexSessionsOptions = { + rootDir?: string; +}; + +export async function listImportableCodexSessions( + opts: ListImportableCodexSessionsOptions = {} +): Promise<{ sessions: ImportableCodexSessionSummary[] }> { + const sessionsRoot = opts.rootDir?.trim() ? opts.rootDir : getCodexSessionsRoot(); + const transcriptPaths = (await collectJsonlFiles(sessionsRoot)).sort((a, b) => a.localeCompare(b)); + const summaries = (await Promise.all(transcriptPaths.map(async (transcriptPath) => scanCodexTranscript(transcriptPath)))) + .filter((summary): summary is ImportableCodexSessionSummary => summary !== null); + + summaries.sort(compareImportableCodexSessions); + + return { sessions: summaries }; +} + +async function scanCodexTranscript(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 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; + + for (const entry of records) { + if (entry.lineIndex <= sessionMetaEntry.lineIndex) { + 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); + + return { + agent: 'codex', + externalSessionId, + cwd, + timestamp, + transcriptPath, + previewTitle, + previewPrompt + }; +} + +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 []; + } +} + +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; +} + +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 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 000000000..fe69d2f6e --- /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 000000000..2c602d6d9 --- /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/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index c8be66202..87e0be5b9 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -17,6 +17,8 @@ export const TITLE_INSTRUCTION = trimIdent(` 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. + If you are a spawned subagent, delegated agent, or child task working inside another conversation, do NOT call the title tool. + Never rename the parent conversation from a subagent. `); /** diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 3b1755903..9c4308e0d 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 diff --git a/cli/src/modules/common/session/BaseSessionScanner.ts b/cli/src/modules/common/session/BaseSessionScanner.ts index e19d0e751..754896354 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/subagents/normalize.ts b/cli/src/subagents/normalize.ts new file mode 100644 index 000000000..f895d2949 --- /dev/null +++ b/cli/src/subagents/normalize.ts @@ -0,0 +1,27 @@ +import type { NormalizedSubagentLifecycleStatus, NormalizedSubagentMeta } from './types'; + +export function createSpawnMeta(input: { + sidechainKey: string; + prompt?: string; +}): NormalizedSubagentMeta { + return { + kind: 'spawn', + sidechainKey: input.sidechainKey, + ...(input.prompt ? { prompt: input.prompt } : {}) + }; +} + +export function createStatusMeta(input: { + sidechainKey: string; + status: NormalizedSubagentLifecycleStatus; + agentId?: string; + nickname?: string; +}): NormalizedSubagentMeta { + return { + kind: 'status', + sidechainKey: input.sidechainKey, + status: input.status, + ...(input.agentId ? { agentId: input.agentId } : {}), + ...(input.nickname ? { nickname: input.nickname } : {}) + }; +} diff --git a/cli/src/subagents/types.ts b/cli/src/subagents/types.ts new file mode 100644 index 000000000..3bfd387c4 --- /dev/null +++ b/cli/src/subagents/types.ts @@ -0,0 +1,16 @@ +export type NormalizedSubagentLifecycleStatus = + | 'waiting' + | 'running' + | 'completed' + | 'error' + | 'closed' + +export type NormalizedSubagentMeta = { + sidechainKey: string + kind: 'spawn' | 'message' | 'status' | 'title' + prompt?: string + title?: string + status?: NormalizedSubagentLifecycleStatus + agentId?: string + nickname?: string +} diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index 6ba5fcdd1..1ce7fa0cb 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -1,43 +1,50 @@ import { logger } from "@/ui/logger"; -interface QueueItem { - message: string; - mode: T; +interface QueueItem { + message: TMessage; + mode: TMode; modeHash: string; isolate?: boolean; // If true, this message must be processed alone } +function defaultCombineMessages(messages: TMessage[]): TMessage { + return messages.join('\n') as TMessage; +} + /** * A mode-aware message queue that stores messages with their modes. * Returns consistent batches of messages with the same mode. */ -export class MessageQueue2 { - public queue: QueueItem[] = []; // Made public for testing +export class MessageQueue2 { + public queue: QueueItem[] = []; // Made public for testing private waiter: ((hasMessages: boolean) => void) | null = null; private closed = false; - private onMessageHandler: ((message: string, mode: T) => void) | null = null; - modeHasher: (mode: T) => string; + private onMessageHandler: ((message: TMessage, mode: TMode) => void) | null = null; + modeHasher: (mode: TMode) => string; + private readonly combineMessages: (messages: TMessage[]) => TMessage; constructor( - modeHasher: (mode: T) => string, - onMessageHandler: ((message: string, mode: T) => void) | null = null + modeHasher: (mode: TMode) => string, + onMessageHandler: ((message: TMessage, mode: TMode) => void) | null = null, + combineMessages: (messages: TMessage[]) => TMessage = defaultCombineMessages as (messages: TMessage[]) => TMessage ) { this.modeHasher = modeHasher; this.onMessageHandler = onMessageHandler; + this.combineMessages = combineMessages; logger.debug(`[MessageQueue2] Initialized`); } /** * Set a handler that will be called when a message arrives */ - setOnMessage(handler: ((message: string, mode: T) => void) | null): void { + setOnMessage(handler: ((message: TMessage, mode: TMode) => void) | null): void { this.onMessageHandler = handler; } /** * Push a message to the queue with a mode. */ - push(message: string, mode: T): void { + push(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -72,7 +79,7 @@ export class MessageQueue2 { * Push a message immediately without batching delay. * Does not clear the queue or enforce isolation. */ - pushImmediate(message: string, mode: T): void { + pushImmediate(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -108,7 +115,7 @@ export class MessageQueue2 { * Clears any pending messages and ensures this message is never batched with others. * Used for special commands that require dedicated processing. */ - pushIsolateAndClear(message: string, mode: T): void { + pushIsolateAndClear(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -145,7 +152,7 @@ export class MessageQueue2 { /** * Push a message to the beginning of the queue with a mode. */ - unshift(message: string, mode: T): void { + unshift(message: TMessage, mode: TMode): void { if (this.closed) { throw new Error('Cannot unshift to closed queue'); } @@ -221,7 +228,7 @@ export class MessageQueue2 { * Wait for messages and return all messages with the same mode as a single string * Returns { message: string, mode: T } or null if aborted/closed */ - async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: string, mode: T, isolate: boolean, hash: string } | null> { + async waitForMessagesAndGetAsString(abortSignal?: AbortSignal): Promise<{ message: TMessage, mode: TMode, isolate: boolean, hash: string } | null> { // If we have messages, return them immediately if (this.queue.length > 0) { return this.collectBatch(); @@ -245,13 +252,13 @@ export class MessageQueue2 { /** * Collect a batch of messages with the same mode, respecting isolation requirements */ - private collectBatch(): { message: string, mode: T, hash: string, isolate: boolean } | null { + private collectBatch(): { message: TMessage, mode: TMode, hash: string, isolate: boolean } | null { if (this.queue.length === 0) { return null; } const firstItem = this.queue[0]; - const sameModeMessages: string[] = []; + const sameModeMessages: TMessage[] = []; let mode = firstItem.mode; let isolate = firstItem.isolate ?? false; const targetModeHash = firstItem.modeHash; @@ -273,7 +280,7 @@ export class MessageQueue2 { } // Join all messages with newlines - const combinedMessage = sameModeMessages.join('\n'); + const combinedMessage = this.combineMessages(sameModeMessages); return { message: combinedMessage, diff --git a/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md b/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md new file mode 100644 index 000000000..8db05f2d4 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-agent-lifecycle-block-implementation.md @@ -0,0 +1,88 @@ +# Codex Agent Lifecycle Block Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Make one Codex subagent appear as one lifecycle block in the parent chat timeline by folding matched wait/send/close control blocks into the owning `CodexSpawnAgent` block. + +**Architecture:** Keep current scanner/converter/sidechain pipeline. Add a reducer-level lifecycle aggregation pass after normal block reduction, attach lifecycle metadata to spawn blocks, filter matched control blocks from the root timeline, and render spawn blocks with a lifecycle card instead of a generic tool card. + +**Tech Stack:** TypeScript, React, Bun, Vitest. + +--- + +## File map + +### Create +- optional: `web/src/chat/codexLifecycle.ts` +- optional: `web/src/chat/codexLifecycle.test.ts` + +### Modify +- `web/src/chat/types.ts` +- `web/src/chat/reducer.ts` +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- `web/src/chat/reducer.test.ts` + +Prefer a helper file for lifecycle aggregation if reducer gets noisy. + +--- + +### Task 1: Add RED reducer tests for lifecycle aggregation + +- [ ] Extend `web/src/chat/reducer.test.ts` with a realistic sequence: + - spawn call/result + - child user/agent transcript + - wait call/result targeting same `agent_id` + - optional send/close targeting same `agent_id` +- [ ] Assert: + - root timeline contains one `CodexSpawnAgent` block + - matched `CodexWaitAgent` is removed from root timeline + - spawn block gets lifecycle metadata with completed/waiting state + - child transcript remains under `spawnBlock.children` + +### Task 2: Implement lifecycle aggregation + +- [ ] Add typed lifecycle metadata in `web/src/chat/types.ts` +- [ ] Implement helper in `web/src/chat/reducer.ts` or `web/src/chat/codexLifecycle.ts`: + - build `agentId -> spawn block` + - fold matched wait/send/close blocks into spawn lifecycle metadata + - filter matched control blocks from returned root blocks +- [ ] Keep unmatched control blocks visible + +### Task 3: Upgrade lifecycle card rendering + +- [ ] Update `CodexSubagentPreviewCard.tsx`: + - show status pill / label + - show latest lifecycle text if available + - show condensed action count or latest action +- [ ] Update `ToolMessage.tsx`: + - for lifecycle-enabled `CodexSpawnAgent`, render lifecycle card as the primary block + - do not also render the generic tool card in the main timeline + - keep dialog transcript behavior + +### Task 4: GREEN tests and verification + +- [ ] Focused tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +- [ ] Broader safety tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` + +### Task 5: Commit + +- [ ] Commit lifecycle-block UI/reducer changes. + +Suggested message: +```bash +git commit -m "feat(web): merge codex agent lifecycle blocks" +``` diff --git a/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md b/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md new file mode 100644 index 000000000..1e6510a0e --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-subagent-clickable-card-implementation.md @@ -0,0 +1,133 @@ +# Codex Subagent Clickable Card Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Replace the current always-inline Codex nested child rendering with a dedicated clickable preview card/dialog for `CodexSpawnAgent` blocks that already have `children`. + +**Architecture:** Keep the current CLI/reducer sidechain pipeline intact. Implement a view-layer special-case in the assistant chat renderer: `CodexSpawnAgent + children => preview card + dialog`, using the existing nested renderer inside the dialog. + +**Tech Stack:** TypeScript, React, assistant chat components, shadcn dialog, Vitest. + +--- + +## File map + +### Create +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` + +### Modify +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` +- `web/src/chat/reducer.test.ts` +- optionally `web/src/components/ToolCard/knownTools.tsx` only if summary text helper reuse is needed + +Do not modify reducer/schema unless blocked. + +--- + +### Task 1: Add RED tests for clickable Codex subagent preview behavior + +**Files:** +- Create: `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- Modify if needed: `web/src/chat/reducer.test.ts` + +- [ ] Add a component test for a `CodexSpawnAgent` block with children. + - render `HappyToolMessage` or the new preview component with a realistic `ToolCallBlock` + - assert collapsed view shows: + - subagent label/card text + - prompt preview or agent id when present + - assert child prompt / child answer are **not** visible before open + - click preview/button + - assert dialog now shows child prompt / child answer + +- [ ] Add/keep reducer integration assertion that `CodexSpawnAgent.children` is populated and root timeline does not contain duplicate flat child text. + +- [ ] Run focused web tests; confirm RED. + +Suggested command: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +--- + +### Task 2: Implement Codex subagent preview card component + +**Files:** +- Create: `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` + +- [ ] Build compact card UI. + - heading: `Subagent conversation` + - secondary info from spawn tool input/result: + - nickname + - agent id + - delegated prompt preview + - child block count + - affordance: button/row with open icon + +- [ ] Add dialog body. + - dialog title can use nickname or fallback `Subagent conversation` + - dialog content renders nested child transcript with the existing nested block renderer path + +- [ ] Keep implementation local/simple. + - no new route + - no new global state + +--- + +### Task 3: Wire ToolMessage special-case + +**Files:** +- Modify: `web/src/components/AssistantChat/messages/ToolMessage.tsx` + +- [ ] Extract a shared helper for rendering tool children if that reduces duplication. + +- [ ] Add special-case: + - when `block.tool.name === 'CodexSpawnAgent' && block.children.length > 0` + - render `CodexSubagentPreviewCard` + - suppress the default inline nested block list for that block + +- [ ] Preserve current behavior for: + - `Task` + - all non-`CodexSpawnAgent` tools + - nested render inside dialog + +--- + +### Task 4: GREEN tests + manual verification + +**Files:** +- no new files beyond above unless a tiny test helper is required + +- [ ] Run focused tests: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/reducer.test.ts src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +``` + +- [ ] Run broader web safety checks: +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` + +- [ ] Manual dev-web verification on real Codex parent session. + - confirm `CodexSpawnAgent` shows clickable subagent card + - confirm child transcript opens in dialog + - confirm child transcript no longer floods main timeline by default + +--- + +### Task 5: Commit + +- [ ] Commit only the UI work for clickable Codex subagent preview/dialog. + +Suggested commit message: +```bash +git commit -m "feat(web): add codex subagent preview dialog" +``` diff --git a/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md b/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md new file mode 100644 index 000000000..e95520160 --- /dev/null +++ b/docs/superpowers/plans/2026-04-02-codex-subagent-dialog-polish-implementation.md @@ -0,0 +1,36 @@ +# Codex Subagent Dialog Polish Implementation Plan + +> REQUIRED SUB-SKILL: superpowers:subagent-driven-development + +**Goal:** Polish the Codex lifecycle/subagent dialog by removing duplicated prompt display, improving transcript rendering, and adding explicit close affordance. + +**Architecture:** Keep lifecycle aggregation unchanged. Adjust only the lifecycle card/dialog rendering layer and its focused tests. + +## Files + +### Modify +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx` +- optionally `web/src/components/AssistantChat/messages/ToolMessage.tsx` only if required by the close flow + +## Tasks + +### Task 1: RED tests +- add test for delegated prompt dedupe +- add test for explicit close button +- add test that markdown child agent text renders through markdown path + +### Task 2: Implement polish +- dedupe first repeated user prompt block in dialog transcript +- render child `agent-text` via `MarkdownRenderer` +- add explicit `Close` button in dialog footer +- keep lifecycle summary as the single wait/completed surface + +### Task 3: Verify +```bash +export PATH="$HOME/.bun/bin:$PATH" +cd /home/xiaoxiong/workplace/hapi-dev/web +bun run test -- src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx src/chat/reducer.test.ts +bun run test -- src/chat/normalize.test.ts src/chat/codexSidechain.test.ts src/chat/reducer.test.ts src/components/ToolCard/views/_results.test.tsx src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx +bun run typecheck +``` diff --git a/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md b/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md new file mode 100644 index 000000000..1d9369b5f --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-agent-lifecycle-block-design.md @@ -0,0 +1,191 @@ +# Codex Agent Lifecycle Block Design + +## Summary + +Upgrade the current Codex subagent UI from a **tool-centric** view to an **agent-centric lifecycle block**. + +Current state: +- `CodexSpawnAgent` renders as one tool block +- `CodexWaitAgent` renders as another tool block +- `CodexSendInput` / `CodexCloseAgent` can also appear as separate blocks +- child transcript opens through a clickable preview card + +Target state: +- one Codex subagent = one primary block in the parent chat timeline +- the block keeps updating as the agent progresses +- control steps like wait/send/close no longer clutter the root timeline as separate blocks when they belong to the same agent +- click opens the child transcript and final details + +Scope: +- Codex only +- in-session only +- no child session/page +- no new route + +## Problem + +The current UI is still too close to raw tools. + +That creates two UX problems: +1. users see multiple blocks for one logical subagent run +2. result displays often feel technical / JSON-shaped instead of task-shaped + +Even after clickable preview work, the parent timeline can still look like: +- Spawn agent +- Wait for agent +- Send input +- Close agent + +This is accurate for debugging, but not ideal for end users. + +## Goal + +Represent a Codex subagent run like a long-running execution block: +- created +- running +- waiting +- completed / errored +- expandable for details + +## Non-goals + +Out of scope: +- child session model +- session tree +- Claude parity +- replacing all raw tool detail UIs +- changing scanner/converter protocol again + +## Recommended approach + +### Option A — lifecycle aggregation at reducer level (recommended) +Keep raw messages as-is, but aggregate related Codex control tool blocks into the parent spawn block during block reduction. + +Effects: +- spawn block becomes lifecycle owner +- matched wait/send/close blocks disappear from root timeline +- lifecycle state is attached to the spawn block +- existing child transcript nesting remains attached to the same block + +Pros: +- true “one agent one block” effect in timeline +- minimal CLI changes +- preserves raw transcript semantics underneath + +Cons: +- reducer gets provider-specific aggregation logic + +### Option B — presentation-only hiding +Leave all blocks in reducer output, but hide wait/send/close blocks in rendering. + +Pros: +- smaller change + +Cons: +- awkward hidden-state bookkeeping +- duplicated data still exists in root timeline +- harder to reason about ordering and updates + +## Scope decision + +Use **Option A**. + +## Proposed design + +### 1. Introduce Codex lifecycle metadata on `ToolCallBlock` + +Add optional metadata for Codex subagent lifecycle state. + +Suggested shape: +- `kind: 'codex-agent-lifecycle'` +- `agentId?: string` +- `nickname?: string` +- `status: 'running' | 'waiting' | 'completed' | 'error' | 'closed'` +- `latestText?: string` +- `actions: Array<{ type: 'wait' | 'send' | 'close'; createdAt: number; summary: string }>` +- `hiddenToolIds: string[]` + +This metadata attaches to the owning `CodexSpawnAgent` block. + +### 2. Aggregate related control blocks into the spawn block + +Reducer pass after normal block creation: +- find `CodexSpawnAgent` blocks with `agent_id` in tool result +- map `agent_id -> spawn block` +- match later tool blocks: + - `CodexWaitAgent` by `input.targets[]` + - `CodexSendInput` by `input.target` + - `CodexCloseAgent` by `input.target` +- update lifecycle metadata on the matching spawn block +- remove matched control blocks from root timeline + +### 3. Lifecycle status rules + +Default after spawn: +- `running` + +Wait result rules: +- status map says running/in_progress -> `waiting` +- status map says completed -> `completed` +- status map says failed/error -> `error` +- completed text becomes `latestText` + +Send input rules: +- append action summary +- lifecycle stays `running`/`waiting` + +Close rules: +- append action summary +- if no stronger completed/error state, can become `closed` + +### 4. Replace spawn tool card with lifecycle card in chat view + +For a `CodexSpawnAgent` block with lifecycle metadata: +- render the lifecycle card as the main visible block +- do not separately render a generic `ToolCard` for that same spawn block in the main timeline +- clicking opens dialog with child transcript and optional lifecycle details + +### 5. Dialog content + +Dialog should show: +- prompt summary +- status summary / latest update +- child transcript +- optional action timeline + +Raw JSON stays out of the default surface. + +## Success criteria + +For one Codex subagent run in the parent timeline: +- only one primary lifecycle block is visible +- wait/send/close no longer appear as separate root-level blocks when matched to that same agent +- the lifecycle block shows human-readable status +- clicking opens nested child transcript and result details + +## Risks + +### Risk 1 — incorrect tool matching +A wait/send/close block may target an unrelated id. + +Mitigation: +- only aggregate when the target matches a known spawn `agent_id` +- unmatched control blocks stay visible as normal blocks + +### Risk 2 — hiding useful debugging information +Merging control blocks could make debugging harder. + +Mitigation: +- keep action summaries in lifecycle metadata/dialog +- preserve raw tool views for unmatched cases + +### Risk 3 — partial lifecycle in older sessions +Some sessions may have spawn + child transcript but no wait/close block. + +Mitigation: +- lifecycle block still works with spawn-only state +- defaults to `running` unless stronger evidence appears + +## Final decision + +Implement a reducer-level Codex lifecycle aggregation pass so one subagent run becomes one primary lifecycle block, with wait/send/close folded into that block and the child transcript kept behind the clickable dialog. diff --git a/docs/superpowers/specs/2026-04-02-codex-resume-design.md b/docs/superpowers/specs/2026-04-02-codex-resume-design.md new file mode 100644 index 000000000..1d1b66ba0 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-resume-design.md @@ -0,0 +1,294 @@ +# Codex Resume Deterministic Recovery Design + +## Summary + +Improve HAPI Codex resume so explicit `hapi codex resume ` behaves deterministically and only reports success when both transcript recovery and remote thread reattachment succeed. + +This design intentionally targets the first high-value fix only: +- deterministic transcript resolution by `sessionId` +- strict remote reattach to the original Codex thread +- no fuzzy adoption when user explicitly requested a session id + +Out of scope for this design: +- injecting parsed history into app-server `thread/resume(history)` +- improving UI rendering for compaction/token/context events +- broad refactors of Codex event conversion + +## Problem + +Current Codex resume mixes two different concerns: + +1. finding the correct local transcript file +2. reattaching remote control to the original live thread + +Today, explicit resume still flows through scanner logic designed for ambiguous adoption: +- scanner walks `~/.codex/sessions/YYYY/MM/DD` +- matching depends on `cwd`, timestamps, time window, recent activity +- date filtering can exclude older session files entirely + +This is too conservative for explicit user intent. A known `sessionId` should not be treated like an unknown session. + +Current remote behavior is also too permissive: +- remote launcher attempts `thread/resume(threadId)` +- if it fails, it silently falls back to `thread/start` + +That creates false success semantics: +- history may appear +- a new live thread may be created +- user thinks the original session was resumed, but it was not + +## Goals + +### Functional goals +- Explicit resume resolves the Codex transcript file directly from `sessionId` +- Transcript recovery is deterministic; no fuzzy matching in explicit resume mode +- Remote attach must reconnect to the original thread id +- If remote reattach fails, overall resume fails +- No silent fallback to a new thread during explicit resume + +### Success criteria +An explicit Codex resume is only considered successful if all are true: +- exactly one matching transcript file is resolved +- transcript history is replayed into HAPI +- remote `thread/resume` succeeds for the same session/thread id +- subsequent live turns continue on that same thread + +### Non-goals +- perfect recovery for sessions with corrupted or missing local transcript files +- redesigning Codex app-server protocol usage +- solving all missing Codex event/UI fidelity issues + +## Reference: why Claude is more reliable + +Claude has two properties Codex currently lacks: + +1. deterministic file path derivation + - `cwd -> project dir` + - `sessionId -> exact jsonl path` +2. explicit runtime session continuity updates + - hook-based session notifications + - SDK/init path updates HAPI when session id changes + +Codex cannot copy this 1:1 today because HAPI does not have an equivalent Codex session-start hook path. But it can adopt the key reliability principle: +- file-first deterministic recovery for explicit resume +- strict remote continuation semantics + +## Proposed architecture + +Split explicit Codex resume into two independent but coordinated stages. + +### Stage A: deterministic transcript attach + +For explicit `resumeSessionId`: +- resolve `CODEX_HOME|~/.codex/sessions/**/*-.jsonl` +- require exactly one match +- parse first line `session_meta` +- validate `session_meta.payload.id === resumeSessionId` +- use that file directly for history replay and incremental watch + +Behavioral rules: +- do not run fuzzy `cwd + timestamp + recent activity` adoption +- do not use session date prefix narrowing for explicit resume +- do not adopt a different session if lookup fails + +### Stage B: strict remote thread attach + +For explicit `resumeSessionId` in remote mode: +- call `thread/resume({ threadId: resumeSessionId, ...threadParams })` +- if success, continue normal turn flow +- if failure, explicit resume fails immediately +- do not fall back to `thread/start` + +Behavioral rules: +- explicit resume means attach original thread or fail +- new thread creation is allowed only for non-resume sessions + +## Components and file responsibilities + +### New: deterministic resolver +**File:** `cli/src/codex/utils/resolveCodexSessionFile.ts` + +Responsibility: +- find transcript file(s) for a specific Codex session id +- classify result as `found`, `not_found`, or `ambiguous` +- validate `session_meta` +- return structured metadata needed by launcher/scanner + +Proposed return shape: +- `status: 'found' | 'not_found' | 'ambiguous' | 'invalid'` +- `sessionId` +- `filePath?` +- `cwd?` +- `timestamp?` +- `matches?` +- `reason?` + +Why separate helper: +- avoids embedding lookup policy inside scanner internals +- enables focused unit tests +- reusable by launchers and future diagnostics + +### Change: Codex session scanner +**File:** `cli/src/codex/utils/codexSessionScanner.ts` + +Responsibility after change: +- support two modes + 1. explicit deterministic mode + 2. existing fallback adoption mode + +In explicit deterministic mode: +- scanner receives resolved `filePath` and `sessionId` +- only scans/watches the target file +- skips fuzzy candidate selection, date prefix filtering, recent-activity heuristics +- replays transcript and follows appended events from that file + +In fallback adoption mode: +- preserve current behavior for non-resume sessions or unknown-session adoption + +### Change: local launcher +**File:** `cli/src/codex/codexLocalLauncher.ts` + +Responsibility after change: +- if `resumeSessionId` exists, resolve transcript before building scanner +- pass deterministic resolution into scanner +- emit explicit user-visible failure if deterministic resolution fails + +Local launcher still launches `codex resume ` for Codex CLI, but HAPI no longer treats scanner fuzzy matching as acceptable recovery for explicit resume. + +### Change: remote launcher +**File:** `cli/src/codex/codexRemoteLauncher.ts` + +Responsibility after change: +- explicit resume uses strict `thread/resume` +- if `thread/resume` fails, abort explicit resume path +- remove silent `resume -> startThread` fallback for explicit resume +- `thread/start` remains valid only when there is no explicit resume session id + +## State model + +### Explicit resume state machine +1. receive `resumeSessionId` +2. resolve transcript file +3. if resolve fails -> overall resume failure +4. initialize deterministic scanner on resolved file +5. start transcript replay/watch +6. remote launcher calls `thread/resume(resumeSessionId)` +7. if remote attach fails -> overall resume failure +8. if remote attach succeeds -> live turns proceed normally + +### Failure semantics + +#### Transcript resolve failure +Examples: +- no matching file +- multiple matching files +- invalid first line +- `session_meta.payload.id` mismatch + +Result: +- explicit resume fails +- no fuzzy adoption fallback +- message explains exact cause + +#### Transcript ok, remote attach fails +Result: +- overall explicit resume fails +- message should make clear that history may be present but original live thread was not reattached +- no fallback new thread creation + +#### Remote attach ok, transcript missing +Result: +- overall explicit resume fails under this design +- success requires both transcript recovery and remote reattach + +## User-visible behavior + +For explicit resume, user-visible messaging should reflect hard truth, not best-effort ambiguity. + +Examples: +- `Resolved Codex transcript for session : ` +- `Failed to resolve Codex transcript for session : not found` +- `Failed to reattach Codex remote thread ; explicit resume aborted` + +Important: +- do not emit messages that imply success before both stages are complete +- do not silently continue on a replacement thread + +## Testing strategy + +Write necessary tests only. + +### 1. Resolver unit tests +**File:** `cli/src/codex/utils/resolveCodexSessionFile.test.ts` + +Cover: +- one matching file -> found +- zero matching files -> not_found +- multiple matching files -> ambiguous +- invalid first line / invalid json -> invalid +- `session_meta` missing or wrong id -> invalid + +### 2. Scanner tests +**File:** `cli/src/codex/utils/codexSessionScanner.test.ts` (new or existing test coverage extension) + +Cover: +- explicit deterministic mode scans only the resolved file +- explicit mode replays history from resolved file +- explicit mode watches increments on same file +- explicit mode does not use fuzzy adoption when resolution fails +- fallback adoption mode remains unchanged for non-resume flows + +### 3. Remote launcher tests +Target file: +- `cli/src/codex/codexRemoteLauncher.ts` behavior tests + +Cover: +- explicit resume + `resumeThread` success -> no `startThread` +- explicit resume + `resumeThread` failure -> overall failure, no `startThread` +- no explicit resume -> `startThread` path still works + +## Risks and mitigations + +### Risk: strictness may surface failures that were previously hidden +Mitigation: +- this is intended +- false success is worse than explicit failure for resume semantics + +### Risk: old sessions with missing local files cannot be resumed +Mitigation: +- acceptable in this phase +- future work can explore history injection or alternate recovery paths + +### Risk: scanner changes break non-resume adoption +Mitigation: +- isolate deterministic mode from existing fallback mode +- add tests to preserve current non-resume behavior + +## Later extensions + +Not part of this design, but compatible with it: +- parse transcript into app-server `history` for `thread/resume` +- better conversion of compaction/context/token events +- richer diagnostics command for Codex session lookup + +## Implementation recommendation + +Implement in this order: +1. add deterministic resolver helper and tests +2. thread resolver into local launcher + scanner explicit mode +3. enforce strict remote `thread/resume` semantics +4. add remote behavior tests +5. manually validate with known real session ids + +## Manual validation targets + +Use known dedicated sessions only. Current examples from notes: +- `019d3c3f-ba61-71f1-9316-c6d73e4c0aa4` +- `019d49c4-18fa-73b2-a9f9-202fa9c1966c` +- `019d4482-4e6b-7b90-be84-f4b400b7b69d` + +Validation expectations: +- transcript path resolves directly by id +- HAPI history appears from that transcript +- remote attaches to same thread id +- no new replacement thread is created on resume failure diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md new file mode 100644 index 000000000..14df7b6cc --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-clickable-card-design.md @@ -0,0 +1,249 @@ +# Codex Subagent Clickable Card Design + +## Summary + +Add a **Codex-first clickable subagent card/dialog UI** on top of the existing in-session nesting pipeline. + +Scope: +- keep single parent session +- no child session model +- no SessionList tree +- no standalone route/page +- no provider-wide redesign + +Goal: +- when `CodexSpawnAgent` has nested child blocks, show a **subagent preview card** instead of dumping those child blocks inline under the main chat flow +- clicking that card opens a dialog with the nested child transcript +- preserve existing `block.children` data model + +This is a UI follow-up to the already completed data-path work: +- `docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md` +- `docs/superpowers/plans/2026-04-02-codex-block-and-subagent-nesting-implementation.md` + +## Problem + +Current behavior after the recent Codex work: +- parent replay can attach child transcript events +- web normalization preserves sidechain metadata +- reducer groups child messages into `CodexSpawnAgent.children` + +But rendering still uses the generic nested-block path: +- `ToolMessage.tsx` +- `HappyNestedBlockList` + +So child content is rendered as a plain indented block list. + +This is better than flat root duplication, but it still does **not** feel like a dedicated subagent interaction. + +The user expectation is closer to co-Code: +- visible subagent frame/card under the spawn tool +- clear subagent identity / prompt summary / message count +- click to inspect the child dialog transcript + +## Evidence + +### Data path already exists +Files now in place: +- `cli/src/codex/utils/codexSessionScanner.ts` +- `cli/src/codex/utils/codexEventConverter.ts` +- `web/src/chat/normalizeAgent.ts` +- `web/src/chat/normalizeUser.ts` +- `web/src/chat/reducer.ts` + +Key fact: +- child transcript blocks already land in `CodexSpawnAgent.children` + +So the missing piece is mostly presentation. + +### Current UI path is generic +File: +- `web/src/components/AssistantChat/messages/ToolMessage.tsx` + +Current behavior: +- any non-`Task` tool with children renders + - `div.mt-2.pl-3` + - `HappyNestedBlockList blocks={block.children}` + +There is no `CodexSpawnAgent` special-case renderer. + +## Goals + +### Functional goals +- Detect `CodexSpawnAgent` blocks with nested children +- Render a dedicated subagent summary card below the spawn tool card +- Open a dialog/sheet/popover containing the nested child transcript +- Keep nested child blocks **out of the default always-open inline view** +- Preserve current root timeline and tool block ordering + +### UX goals +- visually obvious: “this spawn produced a child agent conversation” +- compact in main timeline +- easy to inspect details on demand +- child transcript should still render with the existing block renderers once opened + +### Success criteria +For a `CodexSpawnAgent` block with children: +- main timeline shows a dedicated clickable subagent card +- card shows useful summary: + - nickname/agent id when available + - delegated prompt preview when available + - child block count +- clicking card opens a dialog with the nested child transcript +- child blocks no longer render fully expanded inline by default + +## Non-goals + +Out of scope: +- child session route/page +- back button / parent-child session navigation +- changing Claude behavior +- rebuilding `ToolCard` architecture from scratch +- changing reducer grouping semantics unless needed for UI ergonomics + +## Recommended approach + +### Option A — ToolMessage special-case (recommended) +Add a `CodexSpawnAgent`-specific child renderer at the chat-message layer. + +Pattern: +- keep `ToolCard` as-is for the tool itself +- if `block.tool.name === 'CodexSpawnAgent' && block.children.length > 0` + - render a new `CodexSubagentPreviewCard` + - dialog body renders `HappyNestedBlockList` over `block.children` + +Pros: +- minimal blast radius +- reuses existing nested block renderer +- no schema or reducer redesign +- easiest to ship and verify + +Cons: +- special-case lives in view layer +- summary extraction logic needs a small helper + +### Option B — ToolCard internal special-case +Push preview/dialog logic into `ToolCard`. + +Pros: +- all tool-specific UX concentrated in ToolCard + +Cons: +- `ToolCard` becomes responsible for rendering child transcript content +- harder to keep child-dialog-only logic separate from generic tool UI + +### Option C — new block kind for subagent preview +Add a new reducer-emitted `subagent-preview` block kind. + +Pros: +- explicit model + +Cons: +- larger reducer/type churn +- unnecessary because `block.children` already exists + +## Scope decision + +Use **Option A**. + +## Proposed design + +### 1. Add a Codex subagent preview component + +New component candidates: +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` + +Props: +- `block: ToolCallBlock` +- `metadata` +- `api` +- `sessionId` +- `disabled` +- `onDone` + +Responsibilities: +- show compact clickable card +- show summary metadata +- host dialog with nested transcript + +### 2. Summary extraction + +Use existing `CodexSpawnAgent` tool input/result and child blocks. + +Summary candidates: +- title: `Subagent conversation` +- subtitle pieces: + - nickname from tool result + - `agent_id` + - prompt preview from spawn input `message` + - child block count + +Need only lightweight heuristics. + +### 3. Main timeline rendering rule + +In `ToolMessage.tsx` and `HappyNestedBlockList`: +- existing `Task` behavior unchanged +- new branch: + - if block is `CodexSpawnAgent` and has children + - render `CodexSubagentPreviewCard` + - do **not** also inline-expand `block.children` +- for all other tools: + - keep current nested rendering + +### 4. Dialog body rendering + +Dialog body should reuse existing nested renderer: +- `HappyNestedBlockList blocks={block.children}` + +This keeps: +- child user messages +- child agent text +- child tool cards +- child agent events + +### 5. Test strategy + +#### Unit / component tests +Files: +- `web/src/chat/reducer.test.ts` +- `web/src/components/AssistantChat/messages/ToolMessage.test.tsx` or dedicated preview test file + +Need assertions for: +- `CodexSpawnAgent` with children renders preview CTA/card text +- child prompt/answer not rendered inline by default in main collapsed view +- opening dialog shows nested child content +- non-`CodexSpawnAgent` tools keep existing child rendering behavior + +#### Manual validation +Use real Codex parent session already known to contain child transcript replay: +- parent: `019d4c91-685a-7843-8056-c8cd69087727` + +Verify in dev web: +- `CodexSpawnAgent` card visible +- click opens child transcript +- root timeline no longer visually floods with child transcript by default + +## Risks + +### Risk 1 — duplicate rendering +If the preview card is added without suppressing default inline children, child transcript appears twice. + +Mitigation: +- centralize `CodexSpawnAgent` special-case in one helper branch in `ToolMessage.tsx` + +### Risk 2 — poor summary text +Spawn input/result may not always contain nickname/model/message. + +Mitigation: +- graceful fallbacks +- summary can degrade to child count only + +### Risk 3 — inconsistent behavior between top-level and nested lists +Both `HappyToolMessage` and `HappyNestedBlockList` render tool blocks. + +Mitigation: +- extract a shared `renderToolChildren` helper or shared component path + +## Final decision + +Implement a **CodexSpawnAgent clickable preview card + dialog** in the web message layer, reusing existing `block.children` data and nested block renderers, without introducing child sessions or new routes. diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md new file mode 100644 index 000000000..94470d26c --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-dialog-polish-design.md @@ -0,0 +1,93 @@ +# Codex Subagent Dialog Polish Design + +## Summary + +Polish the new Codex lifecycle/subagent UI so it feels user-facing rather than debug-facing. + +This is a narrow follow-up to: +- clickable subagent preview dialog +- lifecycle block aggregation + +Scope: +- Codex only +- web only +- no data-model rewrite + +## Problems + +### 1. Wait block feels redundant +After lifecycle aggregation, the main lifecycle card already communicates waiting/completed state. +Showing a second details-heavy wait surface is unnecessary. + +### 2. Delegated prompt appears twice +The dialog currently shows: +- a summary/prompt section at the top +- then the child transcript starts with the same delegated prompt + +That duplication feels clumsy. + +### 3. Child transcript rendering is weaker than main chat +Subagent transcript currently renders some content as plain text instead of using the richer Markdown path used in the main chat. + +### 4. Dialog lacks an explicit close/return affordance +Relying on overlay/escape/default affordances is not enough. + +## Goals + +- keep one clear lifecycle card in the parent timeline +- simplify dialog surface +- remove duplicated delegated prompt +- make child agent text render like normal chat +- add explicit close button + +## Non-goals + +- no new reducer changes unless required for dedupe heuristics +- no child session routes +- no provider parity work + +## Proposed changes + +### A. Wait block downgrade +For matched Codex lifecycle controls: +- continue folding `CodexWaitAgent` into lifecycle state +- do not expose a separate clickable/details block in the main timeline +- rely on lifecycle card status + latest update instead + +This is already mostly true after lifecycle aggregation; polish just ensures no parallel details surface competes with the lifecycle card. + +### B. Prompt dedupe in dialog +Use a simple heuristic: +- if dialog summary prompt exists +- and first child transcript block is a `user-text` +- and normalized text matches or one contains the other after trim +- hide that first child prompt block inside the dialog transcript view + +This keeps the summary prompt once, not twice. + +### C. Child transcript rendering parity +In the dialog transcript renderer: +- `agent-text` should use `MarkdownRenderer` +- `agent-reasoning` can remain visually quieter, but still preserve formatting cleanly +- keep existing user bubble / tool card rendering + +### D. Explicit close button +Add a footer/button inside the dialog: +- label: `Close` +- closes the dialog directly + +Optional extra copy: +- `Back to conversation` + +Prefer simple `Close`. + +## Success criteria + +- no obvious duplicate delegated prompt in dialog +- child agent response supports Markdown rendering +- explicit close button exists +- lifecycle card remains the single primary entry point + +## Final decision + +Implement a small web-only polish batch in `CodexSubagentPreviewCard.tsx` and its tests, keeping lifecycle aggregation as-is. diff --git a/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md b/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md new file mode 100644 index 000000000..fcaa03ef5 --- /dev/null +++ b/docs/superpowers/specs/2026-04-02-codex-subagent-nesting-design.md @@ -0,0 +1,432 @@ +# Codex In-Session Subagent Nesting Design + +## Summary + +Add **Codex-first in-session subagent nesting** to HAPI. + +Scope: +- no child-session model +- no parent/child session schema +- no SessionList tree +- no standalone subagent page + +Goal: +- keep everything inside the parent session +- render Codex subagent workflow as a nested conversation under the parent tool block +- align Codex behavior with the existing Claude-style `ToolCallBlock.children` model where practical + +This design is an **incremental follow-up** to the existing Codex block-support work: +- `docs/superpowers/specs/2026-04-02-codex-claude-block-support-design.md` +- `docs/superpowers/plans/2026-04-02-codex-block-support-implementation.md` + +That earlier work fixes semantic tool names and block rendering. This new design fixes the missing **subagent conversation nesting**. + +## Problem + +Current HAPI can show Codex subagent-related tools as flat cards: +- `CodexSpawnAgent` +- `CodexWaitAgent` +- `CodexSendInput` +- `CodexCloseAgent` + +But it does **not** yet attach the subagent conversation itself under the parent tool block. + +Current Claude path already has a message-level nesting model: +- `tracer.ts` +- `reduceTimeline.ts` +- `ToolCallBlock.children` + +Claude sidechain messages can be grouped under a parent `Task` tool call. + +Codex currently lacks an equivalent mechanism. + +Result: +- subagent workflow appears as flat tool cards + flat summary text +- the user cannot visually follow the child agent conversation as a nested flow +- the UI feels incomplete even though the underlying transcript contains enough clues to reconstruct nesting + +## Evidence from real Codex transcripts + +Recent local Codex transcripts show the parent/child relationship is partially observable already. + +### Parent transcript signals + +A parent session transcript commonly contains: +- `spawn_agent` function call +- `spawn_agent` function_call_output with: + - `agent_id` + - `nickname` +- `wait_agent` call using that `agent_id` +- `wait_agent` function_call_output with a `status[agent_id]` payload +- a later `...` user-message injected back into the parent transcript + +### Inline child span signals + +Some parent transcripts also inline the child run directly after `spawn_agent`, including: +- `turn_context` +- child `user_message` +- child `agent_message` +- child `task_complete` + +This means Codex subagent content is not always hidden in a separate file. In at least some real cases, it already exists in the same transcript and can be grouped. + +## Goals + +### Functional goals +- Detect Codex subagent spans inside a parent transcript +- Attach child messages to the parent `CodexSpawnAgent` tool block as `children` +- Keep nested rendering inside the existing parent session chat page +- Preserve current flat block rendering for tool cards themselves +- Preserve current Claude nesting behavior + +### UX goals +- A `CodexSpawnAgent` block should expand into a readable nested child conversation when child content is present +- The nested child flow should show: + - child prompt + - child replies + - child tool activity if available in the same transcript +- `wait_agent` / `send_input` / `close_agent` should remain visible as normal tool blocks, but child conversational content should no longer appear as unrelated flat messages + +### Success criteria +After this work, when a parent Codex transcript contains inline child activity: +- the child prompt and child replies render under the matching `CodexSpawnAgent` block +- those child messages do not also remain duplicated in the parent root timeline +- normal parent conversation remains in the root timeline + +## Non-goals + +Out of scope: +- child session pages +- session-level parent/child schema +- SessionList nesting +- loading a separate child transcript file by `agent_id` +- reconstructing every possible Codex subagent lifecycle edge case +- redesigning TeamPanel +- changing Claude provider logic beyond compatibility-safe reuse of existing nesting machinery + +## Current architecture constraints + +### 1. Session model is flat +Files: +- `shared/src/schemas.ts` +- `shared/src/sessionSummary.ts` + +There is no: +- `parentSessionId` +- `rootSessionId` +- `sessionKind` + +So nesting must remain **message-level**, not session-level. + +### 2. Web nesting already exists for Claude sidechain +Files: +- `web/src/chat/tracer.ts` +- `web/src/chat/reducer.ts` +- `web/src/chat/reducerTimeline.ts` + +Current model: +- `traceMessages()` adds `sidechainId` +- `reduceChatBlocks()` groups traced messages by `sidechainId` +- `reduceTimeline()` attaches grouped child blocks to the parent tool block via `block.children` + +This is the right target abstraction to reuse. + +### 3. Codex transcript conversion currently lacks sidechain semantics +Files: +- `cli/src/codex/utils/codexEventConverter.ts` +- `web/src/chat/normalizeAgent.ts` + +Today Codex gives semantic tool names, but not explicit nesting metadata. + +## Recommended approach + +### Option A — web-only heuristic grouping +Infer nesting only in web reducer by looking at flat `CodexSpawnAgent` / `CodexWaitAgent` / notification patterns. + +Pros: +- smaller change set + +Cons: +- poor access to raw transcript structure +- fragile grouping rules +- hard to attach inline child messages cleanly + +### Option B — Codex transcript sidechain normalization at the CLI boundary (recommended) +Teach the Codex transcript conversion path to emit enough metadata for the existing web nesting pipeline to work. + +Pros: +- matches HAPI architecture better +- keeps transcript interpretation close to the source +- lets web reuse existing nesting pipeline + +Cons: +- requires coordinated CLI + web changes + +### Option C — load child transcript files by `agent_id` +Use `spawn_agent.output.agent_id` to resolve a separate child file and replay it under the parent block. + +Pros: +- potentially most complete + +Cons: +- much broader than needed +- touches scanner/resume/file-resolution logic again +- unnecessary for the first useful version + +## Scope decision + +Use **Option B**. + +## Proposed design + +## 1. Introduce Codex sidechain metadata in normalized chat messages + +### Files +- `web/src/chat/types.ts` +- `web/src/chat/normalizeAgent.ts` +- possibly small helper extraction file under `web/src/chat/` + +### Change +Extend normalized message metadata so Codex child messages can point to a parent tool block. + +Recommended shape: +- keep existing `isSidechain: boolean` +- add optional `sidechainKey?: string` + +Meaning: +- `isSidechain` = this message belongs to nested child flow +- `sidechainKey` = parent tool-call id that should own the nested messages + +This preserves current Claude semantics while letting Codex produce nesting without pretending it is the same UUID-based sidechain model. + +## 2. Add a Codex transcript nesting extractor before web reduction + +### Files +- `web/src/chat/reducer.ts` +- new helper: `web/src/chat/codexSidechain.ts` + +### Responsibility +Walk normalized messages and detect Codex subagent spans. + +Inputs available already: +- `CodexSpawnAgent` tool-call +- its tool-result containing `agent_id` +- inline child messages in the same transcript +- `CodexWaitAgent` call targeting that `agent_id` +- `` round-trip messages + +### Core rule +Treat `CodexSpawnAgent` as the parent block anchor. + +When child inline transcript content appears after a `CodexSpawnAgent` result and before control clearly returns to the parent flow, mark those child messages with: +- `isSidechain = true` +- `sidechainKey = ` + +Then the reducer can group them exactly like Claude sidechain messages. + +## 3. Codex child-span detection model + +Use a conservative sequential detector. + +### Parent anchor +A `CodexSpawnAgent` tool-call becomes nestable only after its matching tool-result resolves an `agent_id`. + +### Child span start +A child span begins when one of these appears after the resolved spawn result: +- inline child `turn_context` +- inline child `user_message` +- inline child `agent_message` +- inline child child-tool activity clearly belonging to the spawned run + +### Child span end +A child span ends when one of these occurs: +- parent `CodexWaitAgent` result completes and parent summary resumes +- parent root assistant answer starts +- a new unrelated parent tool chain clearly begins +- transcript ends + +### Conservative bias +If ownership is ambiguous, keep the message in the parent root timeline. +Never hide uncertain content inside a child group. + +## 4. Parent/child binding rule + +### Binding key +Use the parent `CodexSpawnAgent` tool-call id as the `sidechainKey`. + +Why: +- `ToolCallBlock.children` is already keyed by tool-call block id +- avoids introducing session-level ids into the web reducer +- mirrors Claude’s existing parent-tool attachment model + +### Agent id tracking +Keep a temporary runtime map while scanning normalized messages: +- `agentId -> spawnToolCallId` + +This supports: +- `wait_agent.targets` +- future `send_input.target` +- correlation of notification text if needed + +## 5. Message classes that may become nested + +Candidate nested content for the first version: +- child user prompt +- child agent text +- child reasoning +- child tool-call / tool-result when inline in the same transcript +- child ready/task-complete events if they are normalized into visible blocks + +Do **not** nest these by default in v1: +- parent `CodexWaitAgent` block itself +- parent `CodexSendInput` block itself +- parent `CodexCloseAgent` block itself + +Those remain root-level workflow controls. + +## 6. Handling `` + +### First version rule +Keep `` return messages in the root timeline. +Do not attempt to move them into the child span in v1. + +Reason: +- they are explicit parent-visible summaries +- they often act as the bridge back into the parent reply +- moving them could make the parent flow harder to follow + +Possible future refinement: +- dual rendering or compact summary under the child block while preserving parent root text + +But not in this scope. + +## 7. Reuse existing reducer pipeline + +### Reducer change +Today: +- `traceMessages()` creates groups only from Claude-style sidechain tracing + +After change: +- produce traced/grouped messages from two sources: + 1. existing Claude tracer output + 2. Codex sidechain extraction output + +Recommended shape: +- keep `traceMessages()` for Claude path +- add a Codex-specific pass that annotates `sidechainKey` +- update `reduceChatBlocks()` to group by either: + - `msg.sidechainId` for Claude + - `msg.sidechainKey` for Codex + +This avoids forcing Codex into Claude’s UUID prompt-matching logic. + +## 8. Data flow after the change + +### Codex replay/resume path +1. transcript scanner emits normalized Codex tool/messages +2. block-support aliases already normalize raw names to `Codex*` +3. Codex sidechain extractor scans normalized messages +4. inline child messages get `isSidechain + sidechainKey` +5. reducer groups those child messages under the matching `CodexSpawnAgent` block +6. UI renders nested child conversation via existing `ToolCallBlock.children` + +### Claude path +Unchanged: +- existing `tracer.ts` continues to drive Claude nested sidechains + +## 9. Failure handling + +### Missing `agent_id` +If `CodexSpawnAgent` result has no `agent_id`: +- keep flat rendering only +- do not attempt nesting + +### No inline child content +If the parent transcript only has: +- `spawn_agent` +- `wait_agent` +- notification summary +and no inline child conversation: +- keep flat workflow cards +- do not synthesize fake children + +### Ambiguous ownership +If a message cannot be confidently associated with one active spawned agent: +- keep it at root +- prefer false negative over false positive nesting + +## Testing strategy + +Write necessary tests only. + +### 1. Codex sidechain extraction tests +New file: +- `web/src/chat/codexSidechain.test.ts` + +Cover: +- `CodexSpawnAgent` + result with `agent_id` + inline child user/agent messages => child messages get `sidechainKey` +- ambiguous root message remains root +- no `agent_id` => no nesting +- multiple sequential spawns => messages bind to the correct parent + +### 2. Reducer integration tests +File: +- `web/src/chat/reducer.test.ts` or nearby existing reducer tests if present + +Cover: +- child messages end up in `ToolCallBlock.children` under the matching `CodexSpawnAgent` +- child messages do not remain duplicated in root blocks +- parent `wait_agent` stays root-level + +### 3. Manual transcript verification +Use a real Codex transcript known to contain: +- `spawn_agent` +- `wait_agent` +- inline child messages + +Verify in dev web: +- `CodexSpawnAgent` expands to nested conversation +- root timeline no longer shows those child messages twice +- parent summary remains readable + +## Risks and mitigations + +### Risk: over-grouping parent messages as child messages +Mitigation: +- conservative detector +- require resolved spawn result first +- keep ambiguous messages at root + +### Risk: transcript formats vary across Codex versions +Mitigation: +- v1 supports only the real patterns observed locally +- unsupported patterns degrade to flat rendering, not broken rendering + +### Risk: too much coupling to current transcript order +Mitigation: +- isolate detector in one helper file +- test exact observed orderings +- keep reducer contract simple: annotate messages, then group normally + +## Recommended execution order + +1. Finish the outstanding block-support plan gap: + - dedicated result views for Codex subagent tools + - real replay verification +2. Add `sidechainKey` support to normalized/traced messages +3. Implement `codexSidechain.ts` detector +4. Wire reducer grouping for Codex nested children +5. Add focused tests +6. Run manual replay verification on a real subagent transcript + +## Final recommendation + +Implement Codex subagent nesting as a **message-level grouping feature**, not as a session model. + +That gives the behavior you want now: +- parent session only +- nested child conversation inside the chat page +- no schema churn +- strong reuse of the Claude-era reducer/UI pipeline + +It also composes cleanly with the earlier Codex block-support work instead of replacing it. diff --git a/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md b/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md new file mode 100644 index 000000000..703d7b60b --- /dev/null +++ b/docs/superpowers/specs/2026-04-04-claude-subagent-parity-design.md @@ -0,0 +1,349 @@ +# Claude Subagent Parity With Codex UX + +## Goal + +Bring Claude subagent behavior in HAPI up to near-Codex UX parity without forcing Claude to imitate Codex's raw protocol. The product goal is a unified user experience for: + +- nested subagent chat visibility +- parent/child lineage +- child transcript replay on resume/import +- subagent title and lifecycle status +- team/task extraction and visualization + +The technical goal is to preserve each agent's native interfaces while normalizing them into one HAPI semantic layer that hub and web can consume consistently. + +## Non-Goals + +This work does not: + +- replace Claude native `Task` semantics with Codex `spawn_agent` semantics +- redesign the entire chat UI +- invent a new team/task product model beyond the current `TeamState` +- remove Codex-specific capabilities that already work + +## Current State + +### Codex + +Codex already has a mature HAPI adaptation layer: + +- sidechain metadata is normalized from raw event data +- spawn/wait/send/close subagent tools are converted into stable HAPI-visible tool calls +- child transcript linking exists in the Codex scanner +- explicit remote resume can replay prior transcript state +- web has Codex-oriented sidechain annotation and a Codex-specific subagent preview card + +This gives Codex a coherent nested-subagent experience across CLI, hub, and web. + +### Claude + +Claude already has partial building blocks: + +- `Task` tool is recognized and rendered +- Claude SDK messages with `parent_tool_use_id` can be mapped into sidechain messages +- remote resume replay now exists for explicit import/resume flows +- hub team/task extraction can already read Claude assistant `tool_use` blocks in some cases + +But the overall behavior is still weaker than Codex: + +- lineage is not normalized into a stable cross-layer subagent model +- child transcript linking is not first-class +- lifecycle/title extraction is shallow +- web subagent rendering is Codex-specific +- team/task state derived from Claude remains opportunistic rather than intentional + +## Design Principles + +1. Preserve native agent semantics. +Claude stays Claude. Codex stays Codex. + +2. Normalize at the HAPI semantic layer. +Hub and web should consume agent-neutral subagent concepts rather than raw Claude/Codex event shapes. + +3. Keep product UX aligned. +Users should see similar concepts and interaction quality across Codex and Claude even if implementation differs underneath. + +4. Improve Claude without regressing Codex. +Codex remains the stronger implementation baseline and should be used as the reference UX. + +## Proposed Architecture + +Introduce a unified internal subagent semantic layer with two adapters: + +- Codex subagent adapter +- Claude subagent adapter + +Each adapter maps agent-native events and transcript structures into the same semantic outputs. + +### Unified Subagent Semantics + +The normalized layer should support these concepts: + +- `subagent_spawn` +- `subagent_prompt` +- `subagent_message` +- `subagent_status` +- `subagent_title` +- `subagent_lineage` +- `team_delta` + +These are internal HAPI semantics, not new user-facing protocol names that external tools must emit directly. + +### Layer Responsibilities + +#### CLI + +Responsible for deriving normalized subagent semantics from native agent streams and transcript files. + +#### Hub + +Responsible for storing/merging session state and deriving stable `TeamState` updates from normalized semantics. + +#### Web + +Responsible for rendering nested conversation, lifecycle preview, and team/task visualization from normalized data, without Codex-only assumptions. + +## Claude CLI Design + +Claude needs four concrete upgrades. + +### 1. Stable Sidechain Identity + +Current Claude flow relies mainly on `parent_tool_use_id` and transient SDK conversion state. That is enough for simple nested display, but not enough for durable lineage across replay and cross-layer rendering. + +Claude adapter should derive a stable `sidechainKey` from the parent `Task` tool use identity and preserve it through: + +- live SDK conversion +- replay conversion +- interrupted tool result synthesis +- web normalization + +The result should be equivalent in product behavior to Codex's `parentToolCallId` usage. + +### 2. Child Transcript Linking + +Claude should gain an explicit child transcript linking path similar in effect to Codex, but driven by Claude-native evidence. + +The adapter should: + +- detect when a `Task` launch corresponds to a child conversation +- discover child transcript files using Claude-native session/transcript metadata +- attach child transcript events back to the parent subagent chain +- preserve enough lineage metadata for replay and UI grouping + +Linking must be conservative. If a child transcript cannot be linked confidently, HAPI should prefer incomplete linkage over incorrect lineage. + +### 3. Lifecycle Extraction + +Claude adapter should derive lifecycle snapshots for each subagent: + +- waiting +- running +- completed +- error +- closed + +Lifecycle should come from a combination of: + +- `Task` tool use/result pairs +- Claude SDK/system/result events +- transcript evidence when replaying/resuming + +This lifecycle model should match the existing Codex preview card capabilities as closely as possible. + +### 4. Title Extraction + +Claude subagents should have a stable display title with fallback order: + +1. explicit subagent/tool-provided title if available +2. prompt-derived title +3. short session identifier + +This title is for UI clarity and lineage display, not for mutating Claude's own underlying protocol. + +## Codex CLI Design + +Codex should not be reworked functionally. Instead, current Codex behavior should be reorganized behind the same semantic interface used by Claude. + +Expected Codex changes are limited to: + +- extracting current subagent/lifecycle logic into the shared semantic boundary +- leaving existing scanner/replay/preview behavior intact +- ensuring Codex and Claude emit comparable semantic outputs + +## Hub Design + +### Team/Task Extraction + +Current `hub/src/sync/teams.ts` already acts like a cross-agent extraction layer, but it is still partly shaped around raw tool names and partial per-agent assumptions. + +This should become an intentional semantic ingestion layer: + +- CLI-originated normalized subagent/team/task semantics are the primary input +- raw tool-block fallback remains allowed for backward compatibility inside the repo, but should be secondary +- `Task` in Claude and `CodexSpawnAgent`-family semantics in Codex should converge into the same `TeamState` mutation model + +### TeamState Semantics + +The current `TeamState` model remains the product surface: + +- `members` +- `tasks` +- `messages` +- `updatedAt` + +But extraction becomes more reliable: + +- spawned subagent becomes a `member` +- subagent work prompt/description becomes a `task` +- lifecycle transitions update task/member state +- cross-agent coordination messages continue to appear in `messages` + +### Merge/Replay Safety + +Session import/refresh/resume must preserve normalized subagent state consistently. + +Rules: + +- replay should not duplicate already-materialized subagent events +- merge should preserve team/task state integrity +- import/refresh failure cleanup should not leave partial lineage/team state artifacts + +## Web Design + +### 1. Agent-Neutral Sidechain Model + +Current web chat logic still has clear Codex-specific behavior, especially in: + +- `web/src/chat/codexSidechain.ts` +- `web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx` +- `ToolMessage.tsx` render-mode branching + +These should be refactored into agent-neutral subagent rendering primitives. + +Planned shape: + +- generic sidechain/subagent annotation module +- generic subagent preview card +- agent-specific formatting only where presentation genuinely differs + +### 2. Nested Transcript Rendering + +Both Codex and Claude should render nested child work with the same product behavior: + +- root tool call remains visible +- nested child conversation is grouped under that tool call +- task prompt can be summarized while full transcript remains inspectable +- lifecycle badges and recent status remain visible + +### 3. TeamPanel Reliability + +`TeamPanel` should not need major redesign. The improvement should come from stronger data quality. + +Expected result: + +- Claude sessions with subagent activity reliably populate `TeamPanel` +- task/member state feels comparable to Codex sessions +- no special-case Claude panel is introduced + +## Data Flow + +### Live Flow + +1. Agent emits native SDK/app-server/tool/transcript signals +2. Agent adapter converts them to normalized subagent semantics +3. CLI emits HAPI-visible messages/state updates +4. Hub updates session/team state +5. Web normalizes and renders nested subagent and team/task views + +### Replay/Resume Flow + +1. Resume/import identifies prior session/transcript +2. Agent adapter replays transcript through the same semantic conversion layer +3. Duplicate-safe normalization prevents repeated or mis-grouped child events +4. Hub/web consume replayed semantics the same way as live flow + +## Error Handling + +### Claude Lineage Ambiguity + +If Claude child lineage cannot be linked confidently: + +- do not fabricate parent/child linkage +- keep content visible as root-level or minimally grouped content +- avoid wrong grouping over aggressive grouping + +### Partial Lifecycle Evidence + +If lifecycle cannot be fully inferred: + +- preserve last known status +- prefer `running` or `completed` only when evidence is clear +- never mark a subagent `completed` from weak heuristics alone + +### Replay Duplication + +Replay and live streams must share a clear deduplication boundary: + +- dedupe by stable event/message identity where available +- otherwise dedupe by normalized semantic key plus transcript position +- avoid masking real repeated child outputs that are semantically distinct + +## Testing Strategy + +### CLI + +- Claude adapter unit tests for spawn/prompt/status/title extraction +- Claude child transcript linking tests +- Claude replay tests covering nested sidechains +- Codex parity regression tests to ensure no behavior loss + +### Hub + +- normalized team/task extraction tests for Claude and Codex +- replay/import/refresh merge integrity tests +- regression tests for `TeamState` updates from both agents + +### Web + +- sidechain annotation tests for Claude and Codex +- generic subagent preview card tests +- reducer/timeline tests for mixed nested transcripts +- `TeamPanel` rendering tests for Claude-derived state + +## Migration Strategy + +Implement in stages: + +1. define shared internal subagent semantic contracts +2. move Codex logic behind those contracts without changing behavior +3. upgrade Claude adapter to emit the same semantics +4. generalize web subagent rendering from Codex-only to agent-neutral +5. strengthen hub team/task extraction to prefer normalized semantics + +This ordering minimizes risk because Codex remains the reference implementation while Claude catches up. + +## Success Criteria + +This work is successful when: + +- Claude nested subagent conversations are visible and grouped as reliably as Codex +- Claude replay/import/resume preserves child conversation context without obvious duplication +- Claude subagents show useful title and lifecycle state +- Claude sessions populate `TeamPanel` with meaningful member/task state +- web no longer depends on Codex-specific preview/rendering for subagent UX +- Codex behavior does not regress + +## Open Tradeoff Decisions Resolved + +### Why not make Claude emit fake Codex events? + +Because that would optimize for short-term implementation convenience while increasing long-term drift risk. HAPI should normalize semantics, not erase protocol differences. + +### Why keep TeamState instead of inventing a new model? + +Because current product needs are already served by `TeamState`, and the real issue is extraction quality, not missing schema surface. + +### Why keep Codex as the UX reference? + +Because Codex is already the stronger implementation in this area, and users explicitly want Claude to feel as good as that path. diff --git a/hub/src/socket/handlers/cli/sessionHandlers.test.ts b/hub/src/socket/handlers/cli/sessionHandlers.test.ts new file mode 100644 index 000000000..7a39af3bd --- /dev/null +++ b/hub/src/socket/handlers/cli/sessionHandlers.test.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'bun:test' +import type { Store, StoredSession } from '../../../store' +import type { CliSocketWithData } from '../../socketTypes' +import { registerSessionHandlers } from './sessionHandlers' + +type EmittedEvent = { + event: string + data: unknown +} + +class FakeSocket { + readonly id: string + readonly data: Record = {} + readonly emitted: EmittedEvent[] = [] + readonly roomEmits: Array<{ room: string; event: string; data: unknown }> = [] + private readonly handlers = new Map void>() + + constructor(id: string) { + this.id = id + } + + on(event: string, handler: (...args: unknown[]) => void): this { + this.handlers.set(event, handler) + return this + } + + emit(event: string, data: unknown): boolean { + this.emitted.push({ event, data }) + return true + } + + to(room: string): { emit: (event: string, data: unknown) => void } { + return { + emit: (event: string, data: unknown) => { + this.roomEmits.push({ room, event, data }) + } + } + } + + trigger(event: string, data?: unknown): void { + const handler = this.handlers.get(event) + if (!handler) return + if (typeof data === 'undefined') { + handler() + return + } + handler(data) + } +} + +describe('cli session handlers', () => { + it('preserves nested subagent metadata when storing message content', () => { + const addMessage = vi.fn((sessionId: string, content: unknown, localId?: string) => ({ + id: 'message-1', + sessionId, + content, + createdAt: 123, + seq: 1, + localId: localId ?? null + })) + + const store = { + messages: { + addMessage + }, + sessions: { + getSession: () => ({ namespace: 'default' } as StoredSession), + setSessionTodos: () => false, + setSessionTeamState: () => false + } + } as unknown as Store + + const socket = new FakeSocket('cli-socket') + + registerSessionHandlers(socket as unknown as CliSocketWithData, { + store, + resolveSessionAccess: () => ({ ok: true, value: { namespace: 'default' } as StoredSession }), + emitAccessError: () => {} + }) + + const payload = { + role: 'assistant', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'OtherTool', + input: { foo: 'bar' }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + } + } + } + + socket.trigger('message', { + sid: 'session-1', + message: JSON.stringify(payload), + localId: 'local-1' + }) + + expect(addMessage).toHaveBeenCalledTimes(1) + expect(addMessage.mock.calls[0]).toEqual([ + 'session-1', + payload, + 'local-1' + ]) + }) +}) diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f70b3db25..3466feb06 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -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) { diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index bb850c0c3..4c357e707 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/sync/rpcGateway.test.ts b/hub/src/sync/rpcGateway.test.ts new file mode 100644 index 000000000..7bc6f6603 --- /dev/null +++ b/hub/src/sync/rpcGateway.test.ts @@ -0,0 +1,186 @@ +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', + 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' + } + ] + }) + 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 d59ff3b6d..8fe9920c3 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -1,7 +1,26 @@ 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 importableSessionSummarySchema = 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() +}) + +const listImportableSessionsResponseSchema = z.object({ + sessions: z.array(importableSessionSummarySchema) +}) + export type RpcCommandResponse = { success: boolean stdout?: string @@ -230,6 +249,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 618447c7d..5fd2d2741 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -36,6 +36,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 @@ -360,75 +387,77 @@ export class SessionCache { return } - const oldStored = this.store.sessions.getSessionByNamespace(oldSessionId, namespace) - const newStored = this.store.sessions.getSessionByNamespace(newSessionId, namespace) - if (!oldStored || !newStored) { - throw new Error('Session not found for merge') - } - - this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + this.store.runInTransaction(() => { + const oldStored = this.store.sessions.getSessionByNamespace(oldSessionId, namespace) + const newStored = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!oldStored || !newStored) { + throw new Error('Session not found for merge') + } - const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) - if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) { - for (let attempt = 0; attempt < 2; attempt += 1) { - const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) - if (!latest) break - const result = this.store.sessions.updateSessionMetadata( - newSessionId, - mergedMetadata, - latest.metadataVersion, - namespace, - { touchUpdatedAt: false } - ) - if (result.result === 'success') { - break - } - if (result.result === 'error') { - break + this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + + const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) + if (mergedMetadata !== null && mergedMetadata !== newStored.metadata) { + for (let attempt = 0; attempt < 2; attempt += 1) { + const latest = this.store.sessions.getSessionByNamespace(newSessionId, namespace) + if (!latest) break + const result = this.store.sessions.updateSessionMetadata( + newSessionId, + mergedMetadata, + latest.metadataVersion, + namespace, + { touchUpdatedAt: false } + ) + if (result.result === 'success') { + break + } + if (result.result === 'error') { + break + } } } - } - if (newStored.model === null && oldStored.model !== null) { - const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { - touchUpdatedAt: false - }) - if (!updated) { - throw new Error('Failed to preserve session model during merge') + if (newStored.model === null && oldStored.model !== null) { + const updated = this.store.sessions.setSessionModel(newSessionId, oldStored.model, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session model during merge') + } } - } - if (newStored.effort === null && oldStored.effort !== null) { - const updated = this.store.sessions.setSessionEffort(newSessionId, oldStored.effort, namespace, { - touchUpdatedAt: false - }) - if (!updated) { - throw new Error('Failed to preserve session effort during merge') + if (newStored.effort === null && oldStored.effort !== null) { + const updated = this.store.sessions.setSessionEffort(newSessionId, oldStored.effort, namespace, { + touchUpdatedAt: false + }) + if (!updated) { + throw new Error('Failed to preserve session effort during merge') + } } - } - if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { - this.store.sessions.setSessionTodos( - newSessionId, - oldStored.todos, - oldStored.todosUpdatedAt, - namespace - ) - } + if (oldStored.todos !== null && oldStored.todosUpdatedAt !== null) { + this.store.sessions.setSessionTodos( + newSessionId, + oldStored.todos, + oldStored.todosUpdatedAt, + namespace + ) + } - if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) { - this.store.sessions.setSessionTeamState( - newSessionId, - oldStored.teamState, - oldStored.teamStateUpdatedAt, - namespace - ) - } + if (oldStored.teamState !== null && oldStored.teamStateUpdatedAt !== null) { + this.store.sessions.setSessionTeamState( + newSessionId, + oldStored.teamState, + oldStored.teamStateUpdatedAt, + namespace + ) + } - const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) - if (!deleted) { - throw new Error('Failed to delete old session during merge') - } + const deleted = this.store.sessions.deleteSession(oldSessionId, namespace) + if (!deleted) { + throw new Error('Failed to delete old session during merge') + } + }) const existed = this.sessions.delete(oldSessionId) if (existed) { diff --git a/hub/src/sync/syncEngine.test.ts b/hub/src/sync/syncEngine.test.ts new file mode 100644 index 000000000..c296327ff --- /dev/null +++ b/hub/src/sync/syncEngine.test.ts @@ -0,0 +1,735 @@ +import { describe, expect, it } from 'bun:test' +import { Store } from '../store' +import { RpcRegistry } from '../socket/rpcRegistry' +import { SyncEngine } from './syncEngine' + +describe('SyncEngine codex import orchestration', () => { + it('returns the existing hapi session when the external codex session is already imported', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'import-existing', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: session.id + }) + } finally { + engine.stop() + } + }) + + it('imports a codex session by resuming the external session id on an online machine', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'codex' }) + return { + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + return { type: 'success', sessionId: 'spawned-session' } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: 'spawned-session' + }) + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const importSpawnArgs = capturedSpawnArgs as unknown[] + if (importSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${importSpawnArgs.length}`) + } + if (importSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(importSpawnArgs[0])}`) + } + if (importSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(importSpawnArgs[1])}`) + } + if (importSpawnArgs[2] !== 'codex') { + throw new Error(`unexpected spawn agent: ${String(importSpawnArgs[2])}`) + } + if (importSpawnArgs[8] !== 'codex-thread-1') { + throw new Error(`unexpected resume session id: ${String(importSpawnArgs[8])}`) + } + } finally { + engine.stop() + } + }) + + it('preserves the Codex preview title in imported session metadata', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Useful imported title', + previewPrompt: 'Fallback prompt' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + const imported = engine.getOrCreateSession( + 'spawned-import-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: imported.id } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + const imported = engine.getSession(result.sessionId) + expect(imported?.metadata?.name).toBe('Useful imported title') + } finally { + engine.stop() + } + }) + + it('removes a spawned session when import fails to become active', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-import-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionSettled = async () => false + + const result = await engine.importExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Session failed to become active', + code: 'import_failed' + }) + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toBeNull() + expect(engine.getSessionsByNamespace('default')).toHaveLength(0) + } finally { + engine.stop() + } + }) + + it('returns session_not_found when the requested external codex session is missing', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ sessions: [] }) + + const result = await engine.importExternalCodexSession('missing-codex-thread', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Importable Codex session not found', + code: 'session_not_found' + }) + } finally { + engine.stop() + } + }) + + it('re-imports an imported codex session into a new HAPI session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const refreshSpawnArgs = capturedSpawnArgs as unknown[] + if (refreshSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${refreshSpawnArgs.length}`) + } + if (refreshSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(refreshSpawnArgs[0])}`) + } + if (refreshSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(refreshSpawnArgs[1])}`) + } + if (refreshSpawnArgs[2] !== 'codex') { + throw new Error(`unexpected spawn agent: ${String(refreshSpawnArgs[2])}`) + } + if (refreshSpawnArgs[8] !== 'codex-thread-1') { + throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) + } + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ + sessionId: result.sessionId + }) + expect(engine.getSession(imported.id)).toBeDefined() + expect(engine.getSession(imported.id)?.metadata?.codexSessionId).toBeUndefined() + } finally { + engine.stop() + } + }) + + it('falls back to the Codex preview prompt when refreshing imported session metadata', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: null, + previewPrompt: 'Prompt fallback title' + } + ] + }) + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Prompt fallback title') + expect(engine.getSession(imported.id)?.metadata?.codexSessionId).toBeUndefined() + } finally { + engine.stop() + } + }) + + it('keeps the existing imported mapping when re-import replacement fails', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + + ;(engine as any).rpcGateway.spawnSession = async () => { + const spawned = engine.getOrCreateSession( + 'spawned-codex-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionSettled = async () => true + ;(engine as any).rpcGateway.listImportableSessions = async () => ({ + sessions: [ + { + agent: 'codex', + externalSessionId: 'codex-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.codex/sessions/codex-thread-1.jsonl', + previewTitle: 'Imported title', + previewPrompt: 'Imported prompt' + } + ] + }) + ;(engine as any).store.sessions.updateSessionMetadata = () => ({ result: 'error' }) + + const result = await engine.refreshExternalCodexSession('codex-thread-1', 'default') + + expect(result).toEqual({ + type: 'error', + message: 'Failed to detach old imported session mapping', + code: 'refresh_failed' + }) + expect(engine.findSessionByExternalCodexSessionId('default', 'codex-thread-1')).toEqual({ + sessionId: imported.id + }) + expect(engine.getSessionsByNamespace('default')).toHaveLength(1) + } finally { + engine.stop() + } + }) +}) + +describe('SyncEngine claude import orchestration', () => { + it('returns the existing hapi session when the external claude session is already imported', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'import-existing', + { + path: '/tmp/project', + host: 'localhost', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + + const result = await engine.importExternalClaudeSession('claude-thread-1', 'default') + + expect(result).toEqual({ + type: 'success', + sessionId: session.id + }) + } finally { + engine.stop() + } + }) + + it('imports a claude session by resuming the external session id on an online machine', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'claude' }) + return { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/claude-thread-1.jsonl', + previewTitle: 'Imported Claude title', + previewPrompt: 'Imported Claude prompt' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + const imported = engine.getOrCreateSession( + 'spawned-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: imported.id } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.importExternalClaudeSession('claude-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const importSpawnArgs = capturedSpawnArgs as unknown[] + if (importSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${importSpawnArgs.length}`) + } + if (importSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(importSpawnArgs[0])}`) + } + if (importSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(importSpawnArgs[1])}`) + } + if (importSpawnArgs[2] !== 'claude') { + throw new Error(`unexpected spawn agent: ${String(importSpawnArgs[2])}`) + } + if (importSpawnArgs[8] !== 'claude-thread-1') { + throw new Error(`unexpected resume session id: ${String(importSpawnArgs[8])}`) + } + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Imported Claude title') + } finally { + engine.stop() + } + }) + + it('re-imports an imported claude session into a new HAPI session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const machine = engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: machine.id, time: Date.now() }) + + const imported = engine.getOrCreateSession( + 'imported-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + + let capturedSpawnArgs: unknown[] | null = null + ;(engine as any).rpcGateway.listImportableSessions = async (_machineId: string, request: { agent: string }) => { + expect(request).toEqual({ agent: 'claude' }) + return { + sessions: [ + { + agent: 'claude', + externalSessionId: 'claude-thread-1', + cwd: '/tmp/project', + timestamp: 123, + transcriptPath: '/tmp/project/.claude/projects/project/claude-thread-1.jsonl', + previewTitle: null, + previewPrompt: 'Prompt fallback title' + } + ] + } + } + ;(engine as any).rpcGateway.spawnSession = async (...args: unknown[]) => { + capturedSpawnArgs = args + const spawned = engine.getOrCreateSession( + 'spawned-claude-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'claude', + claudeSessionId: 'claude-thread-1' + }, + null, + 'default' + ) + return { type: 'success', sessionId: spawned.id } + } + ;(engine as any).waitForSessionSettled = async () => true + + const result = await engine.refreshExternalClaudeSession('claude-thread-1', 'default') + + expect(result.type).toBe('success') + if (result.type !== 'success') { + throw new Error('expected success result') + } + if (capturedSpawnArgs === null) { + throw new Error('spawn args were not captured') + } + const refreshSpawnArgs = capturedSpawnArgs as unknown[] + if (refreshSpawnArgs.length !== 10) { + throw new Error(`unexpected spawn args length: ${refreshSpawnArgs.length}`) + } + if (refreshSpawnArgs[0] !== 'machine-1') { + throw new Error(`unexpected spawn target: ${String(refreshSpawnArgs[0])}`) + } + if (refreshSpawnArgs[1] !== '/tmp/project') { + throw new Error(`unexpected spawn directory: ${String(refreshSpawnArgs[1])}`) + } + if (refreshSpawnArgs[2] !== 'claude') { + throw new Error(`unexpected spawn agent: ${String(refreshSpawnArgs[2])}`) + } + if (refreshSpawnArgs[8] !== 'claude-thread-1') { + throw new Error(`unexpected resume session id: ${String(refreshSpawnArgs[8])}`) + } + expect(engine.findSessionByExternalClaudeSessionId('default', 'claude-thread-1')).toEqual({ + sessionId: result.sessionId + }) + expect(engine.getSession(imported.id)?.metadata?.claudeSessionId).toBeUndefined() + expect(engine.getSession(result.sessionId)?.metadata?.name).toBe('Prompt fallback title') + } finally { + engine.stop() + } + }) +}) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 6b5be2f1c..cd1bbbecc 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,28 @@ 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' } + +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 +77,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 +167,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: { @@ -378,23 +424,7 @@ export class SyncEngine { return { type: 'error', message: 'Resume session ID unavailable', code: 'resume_unavailable' } } - const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) - if (onlineMachines.length === 0) { - return { type: 'error', message: 'No machine online', code: 'no_machine_online' } - } - - const targetMachine = (() => { - if (metadata.machineId) { - const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) - if (exact) return exact - } - if (metadata.host) { - const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) - if (hostMatch) return hostMatch - } - return null - })() - + const targetMachine = this.selectOnlineMachine(namespace, metadata) if (!targetMachine) { return { type: 'error', message: 'No machine online', code: 'no_machine_online' } } @@ -416,8 +446,8 @@ export class SyncEngine { return { type: 'error', message: spawnResult.message, code: 'resume_failed' } } - const becameActive = await this.waitForSessionActive(spawnResult.sessionId) - if (!becameActive) { + const becameReady = await this.waitForSessionSettled(spawnResult.sessionId) + if (!becameReady) { return { type: 'error', message: 'Session failed to become active', code: 'resume_failed' } } @@ -433,6 +463,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') + } + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { @@ -445,6 +483,409 @@ 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 spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + agent, + undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + undefined + ) + + 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' + } + } + + 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: access.reason === 'access-denied' ? 'session_not_found' : '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 spawnResult = await this.rpcGateway.spawnSession( + sourceResult.machineId, + cwd, + agent, + session.model ?? undefined, + undefined, + undefined, + undefined, + undefined, + externalSessionId, + session.effort ?? undefined + ) + + 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) + + 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 selectOnlineMachine(namespace: string, metadata?: Session['metadata']): Machine | null { + const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) + if (onlineMachines.length === 0) { + return null + } + + if (metadata?.machineId) { + const exact = onlineMachines.find((machine) => machine.id === metadata.machineId) + if (exact) { + return exact + } + } + + if (metadata?.host) { + const hostMatch = onlineMachines.find((machine) => machine.metadata?.host === metadata.host) + if (hostMatch) { + return hostMatch + } + } + + return metadata ? null : onlineMachines[0] ?? null + } + + 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 async resolveImportableSessionTitle( + namespace: string, + externalSessionId: string, + agent: 'codex' | 'claude' + ): Promise { + const sourceResult = await this.findImportableSessionSource(namespace, externalSessionId, agent) + if (sourceResult.type !== 'success') { + return null + } + return this.getBestImportableSessionTitle(sourceResult.session) + } + + 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 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/sync/teams.test.ts b/hub/src/sync/teams.test.ts index 968c6b10f..0f845415c 100644 --- a/hub/src/sync/teams.test.ts +++ b/hub/src/sync/teams.test.ts @@ -1,5 +1,5 @@ import { describe, test, expect } from 'bun:test' -import { applyTeamStateDelta } from './teams' +import { applyTeamStateDelta, extractTeamStateFromMessageContent } from './teams' import type { TeamState, TeamTask } from '@hapi/protocol/types' const baseTeamState: TeamState = { @@ -71,3 +71,145 @@ describe('applyTeamStateDelta - orphan TaskUpdate', () => { expect(tasks[0]).toMatchObject({ id: 'task-1', status: 'in_progress' }) }) }) + +describe('extractTeamStateFromMessageContent - normalized subagent metadata', () => { + test('derives the same normalized delta shape for Claude and Codex subagent metadata', () => { + const codexDelta = extractTeamStateFromMessageContent({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'CodexSpawnAgent', + input: { name: 'worker-1' }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + } + } + }) + + const claudeDelta = extractTeamStateFromMessageContent({ + role: 'assistant', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-2', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-2', + name: 'Task', + input: { name: 'worker-1' } + }] + } + } + } + }) + + const normalizeDeltaShape = (delta: NonNullable) => ({ + _action: delta._action, + members: delta.members?.map(({ name, agentType, status }) => ({ name, agentType, status })), + tasks: delta.tasks?.map(({ id, title, description, status, owner }) => ({ id, title, description, status, owner })), + messages: delta.messages, + description: delta.description, + teamName: delta.teamName + }) + + expect(codexDelta).toBeTruthy() + expect(claudeDelta).toBeTruthy() + expect(normalizeDeltaShape(codexDelta!)).toEqual(normalizeDeltaShape(claudeDelta!)) + expect(normalizeDeltaShape(codexDelta!)).toEqual({ + _action: 'update', + members: [{ name: 'worker-1', agentType: undefined, status: 'active' }], + tasks: [{ + id: 'agent:worker-1', + title: 'Investigate flaky test', + description: undefined, + status: 'in_progress', + owner: 'worker-1' + }], + messages: undefined, + description: undefined, + teamName: undefined + }) + }) + + test('prefers normalized subagent metadata over conflicting raw tool data', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + name: 'CodexSpawnAgent', + input: { + team_name: 'test-team', + name: 'worker-1', + description: 'Raw tool description' + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-3', + prompt: 'Normalized prompt' + } + } + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-1', status: 'active' }], + tasks: [{ title: 'Normalized prompt', status: 'in_progress', owner: 'worker-1' }] + }) + expect(delta?.tasks?.[0]).not.toMatchObject({ + title: 'Raw tool description' + }) + }) + + test('falls back to raw tool parsing when normalized metadata is unusable', () => { + const delta = extractTeamStateFromMessageContent({ + role: 'assistant', + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'tool_use', + id: 'task-4', + name: 'Task', + input: { + team_name: 'test-team', + name: 'worker-2', + description: 'Raw fallback description' + } + }] + } + } + }, + meta: { + subagent: { + kind: 'spawn' + } + } + }) + + expect(delta).toMatchObject({ + members: [{ name: 'worker-2', status: 'active' }], + tasks: [{ title: 'Raw fallback description', status: 'in_progress', owner: 'worker-2' }] + }) + }) +}) diff --git a/hub/src/sync/teams.ts b/hub/src/sync/teams.ts index ba26795e1..1c46706a1 100644 --- a/hub/src/sync/teams.ts +++ b/hub/src/sync/teams.ts @@ -4,6 +4,21 @@ import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { TeamState } from '@hapi/protocol/types' type TeamStateDelta = Partial & { _action?: 'create' | 'delete' | 'update' } +type NormalizedTeamSignal = + | { kind: 'spawn'; sidechainKey: string; prompt?: string } + | { kind: 'status'; sidechainKey: string; status: 'waiting' | 'running' | 'completed' | 'error' | 'closed' } + | { kind: 'title'; sidechainKey: string; title: string } + | { kind: 'message'; sidechainKey: string } + +const SPAWN_TOOL_NAMES = new Set(['Task', 'CodexSpawnAgent']) + +function asRecord(value: unknown): Record | null { + return isObject(value) ? value as Record : null +} + +function isSupportedSpawnToolName(name: string): boolean { + return SPAWN_TOOL_NAMES.has(name) +} function extractToolBlocks(content: Record): Array<{ name: string; input: Record }> { const blocks: Array<{ name: string; input: Record }> = [] @@ -43,6 +58,153 @@ function extractToolBlocks(content: Record): Array<{ name: stri return blocks } +function dedupeNormalizedSignals(signals: NormalizedTeamSignal[]): NormalizedTeamSignal[] { + const seen = new Set() + const deduped: NormalizedTeamSignal[] = [] + + for (const signal of signals) { + const key = signal.kind === 'spawn' + ? `${signal.kind}:${signal.sidechainKey}:${signal.prompt ?? ''}` + : signal.kind === 'status' + ? `${signal.kind}:${signal.sidechainKey}:${signal.status}` + : signal.kind === 'title' + ? `${signal.kind}:${signal.sidechainKey}:${signal.title}` + : `${signal.kind}:${signal.sidechainKey}` + if (seen.has(key)) continue + seen.add(key) + deduped.push(signal) + } + + return deduped +} + +function readNormalizedSubagentSignal(subagent: unknown): NormalizedTeamSignal[] { + const signals: NormalizedTeamSignal[] = [] + const items = Array.isArray(subagent) ? subagent : [subagent] + + for (const item of items) { + if (!isObject(item)) continue + + const kind = typeof item.kind === 'string' ? item.kind : null + const sidechainKey = typeof item.sidechainKey === 'string' ? item.sidechainKey : null + if (!kind || !sidechainKey) continue + + if (kind === 'spawn') { + const prompt = typeof item.prompt === 'string' ? item.prompt : undefined + signals.push({ kind, sidechainKey, ...(prompt ? { prompt } : {}) }) + continue + } + + if (kind === 'status') { + const status = typeof item.status === 'string' ? item.status : null + if (status === 'waiting' || status === 'running' || status === 'completed' || status === 'error' || status === 'closed') { + signals.push({ kind, sidechainKey, status }) + } + continue + } + + if (kind === 'title') { + const title = typeof item.title === 'string' ? item.title : null + if (title) { + signals.push({ kind, sidechainKey, title }) + } + continue + } + + if (kind === 'message') { + signals.push({ kind, sidechainKey }) + } + } + + return signals +} + +function collectNormalizedSubagentMeta(value: unknown, signals: NormalizedTeamSignal[], seen: Set): void { + if (!isObject(value)) return + + const record = value as Record + if (seen.has(record)) return + seen.add(record) + + const meta = asRecord(record.meta) + if (meta && 'subagent' in meta) { + signals.push(...readNormalizedSubagentSignal(meta.subagent)) + } + + for (const nested of Object.values(record)) { + if (Array.isArray(nested)) { + for (const item of nested) { + collectNormalizedSubagentMeta(item, signals, seen) + } + continue + } + + collectNormalizedSubagentMeta(nested, signals, seen) + } +} + +function extractNormalizedSubagentMeta(content: Record): NormalizedTeamSignal[] { + const signals: NormalizedTeamSignal[] = [] + collectNormalizedSubagentMeta(content, signals, new Set()) + return dedupeNormalizedSignals(signals) +} + +function processSpawnSignal( + input: Record, + signal: Extract +): TeamStateDelta | null { + const name = typeof input.name === 'string' ? input.name : null + if (!name) return null + + const agentType = typeof input.subagent_type === 'string' ? input.subagent_type : undefined + const description = signal.prompt + ?? (typeof input.description === 'string' ? input.description : null) + + const delta: TeamStateDelta = { + _action: 'update', + members: [{ name, agentType, status: 'active' }], + updatedAt: Date.now() + } + + if (description) { + delta.tasks = [{ + id: `agent:${name}`, + title: description, + status: 'in_progress', + owner: name + }] + } + + return delta +} + +function extractNormalizedTeamDelta( + blocks: Array<{ name: string; input: Record }>, + signals: NormalizedTeamSignal[] +): TeamStateDelta | null { + const spawnSignals = signals.filter((signal): signal is Extract => signal.kind === 'spawn') + if (spawnSignals.length === 0) { + return null + } + + const spawnBlocks = blocks.filter((block) => isSupportedSpawnToolName(block.name)) + if (spawnBlocks.length === 0) { + return null + } + + let result: TeamStateDelta | null = null + const count = Math.min(spawnSignals.length, spawnBlocks.length) + + for (let index = 0; index < count; index += 1) { + const delta = processSpawnSignal(spawnBlocks[index].input, spawnSignals[index]) + if (delta) { + result = result ? mergeDelta(result, delta) : delta + } + } + + return result +} + function processTeamCreate(input: Record): TeamStateDelta | null { const teamName = typeof input.team_name === 'string' ? input.team_name : null if (!teamName) return null @@ -170,9 +332,17 @@ export function extractTeamStateFromMessageContent(messageContent: unknown): Tea if (record.role !== 'agent' && record.role !== 'assistant') return null if (!isObject(record.content) || typeof record.content.type !== 'string') return null + const normalizedSignals = extractNormalizedSubagentMeta(record as Record) const blocks = extractToolBlocks(record.content) if (blocks.length === 0) return null + if (normalizedSignals.length > 0) { + const normalizedDelta = extractNormalizedTeamDelta(blocks, normalizedSignals) + if (normalizedDelta) { + return normalizedDelta + } + } + let result: TeamStateDelta | null = null for (const block of blocks) { diff --git a/hub/src/web/routes/importableSessions.test.ts b/hub/src/web/routes/importableSessions.test.ts new file mode 100644 index 000000000..2efa18d63 --- /dev/null +++ b/hub/src/web/routes/importableSessions.test.ts @@ -0,0 +1,270 @@ +import { describe, expect, it } from 'bun:test' +import { Hono } from 'hono' +import type { SyncEngine } from '../../sync/syncEngine' +import type { WebAppEnv } from '../middleware/auth' +import { createImportableSessionsRoutes } from './importableSessions' + +function createApp(engine: Partial) { + 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' + }, + { + 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', + 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 000000000..6c5e45b96 --- /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/server.test.ts b/hub/src/web/server.test.ts new file mode 100644 index 000000000..12649523b --- /dev/null +++ b/hub/src/web/server.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'bun:test' +import { resolveMaxRequestBodySize } from './server' + +describe('resolveMaxRequestBodySize', () => { + it('does not inherit the socket handler 1MB limit for normal HTTP routes', () => { + expect(resolveMaxRequestBodySize(1_000_000)).toBe(100 * 1024 * 1024) + }) + + it('preserves larger socket handler limits when they already exceed the upload floor', () => { + expect(resolveMaxRequestBodySize(150 * 1024 * 1024)).toBe(150 * 1024 * 1024) + }) +}) diff --git a/hub/src/web/server.ts b/hub/src/web/server.ts index 08800fc72..a77a820c4 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' @@ -28,6 +29,8 @@ import { loadEmbeddedAssetMap, type EmbeddedWebAsset } from './embeddedAssets' import { isBunCompiled } from '../utils/bunCompiled' import type { Store } from '../store' +const MIN_HTTP_REQUEST_BODY_SIZE = 100 * 1024 * 1024 + function findWebappDistDir(): { distDir: string; indexHtmlPath: string } { const candidates = [ join(process.cwd(), '..', 'web', 'dist'), @@ -54,6 +57,10 @@ function serveEmbeddedAsset(asset: EmbeddedWebAsset): Response { }) } +export function resolveMaxRequestBodySize(socketHandlerMaxRequestBodySize: number): number { + return Math.max(socketHandlerMaxRequestBodySize, MIN_HTTP_REQUEST_BODY_SIZE) +} + function createWebApp(options: { getSyncEngine: () => SyncEngine | null getSseManager: () => SSEManager | null @@ -91,6 +98,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)) @@ -234,7 +242,7 @@ export async function startWebServer(options: { hostname: configuration.listenHost, port: configuration.listenPort, idleTimeout: Math.max(30, socketHandler.idleTimeout), - maxRequestBodySize: socketHandler.maxRequestBodySize, + maxRequestBodySize: resolveMaxRequestBodySize(socketHandler.maxRequestBodySize), websocket: socketHandler.websocket, fetch: (req, server) => { const url = new URL(req.url) diff --git a/shared/package.json b/shared/package.json index ca7ba8204..835bd2100 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/index.ts b/shared/src/index.ts index e9e6e3501..bfbd4cd4a 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -4,4 +4,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 000000000..5101fe29f --- /dev/null +++ b/shared/src/rpcTypes.ts @@ -0,0 +1,33 @@ +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 +} + +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/web/src/api/client.ts b/web/src/api/client.ts index 163eb206d..de252c753 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -3,10 +3,12 @@ import type { AuthResponse, CodexCollaborationMode, DeleteUploadResponse, + ExternalSessionActionResponse, ListDirectoryResponse, FileReadResponse, FileSearchResponse, GitCommandResponse, + ImportableSessionsResponse, MachinePathsExistsResponse, MachinesResponse, MessagesResponse, @@ -20,7 +22,8 @@ import type { UploadFileResponse, VisibilityPayload, SessionResponse, - SessionsResponse + SessionsResponse, + ImportableSessionAgent, } from '@/types/api' type ApiClientOptions = { @@ -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') } diff --git a/web/src/chat/codexLifecycle.ts b/web/src/chat/codexLifecycle.ts new file mode 100644 index 000000000..bf8822f19 --- /dev/null +++ b/web/src/chat/codexLifecycle.ts @@ -0,0 +1,279 @@ +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 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 summarizeWaitResult(block: ToolCallBlock, targets: string[]): { status: CodexAgentLifecycleStatus | null; summary: string } { + const result = block.tool.result + const resultObject = isObject(result) ? result : null + 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}` + } + } + + if (isObject(resultObject.statuses)) { + const parts: string[] = [] + let status: CodexAgentLifecycleStatus | null = null + let singleTargetMessage: string | null = null + for (const target of targets) { + const raw = resultObject.statuses[target] + const rawStatus = typeof raw === 'string' + ? raw + : isObject(raw) && typeof raw.status === 'string' + ? raw.status + : isObject(raw) && typeof raw.completed === 'string' + ? raw.completed + : null + const rawMessage = isObject(raw) && typeof raw.message === 'string' && raw.message.trim().length > 0 + ? raw.message.trim() + : null + if (rawStatus) { + const normalized = normalizeLifecycleStatus(rawStatus) + if (normalized) { + status = status ? pickHigherStatus(status, normalized) : normalized + } + parts.push(`${target}: ${rawStatus}`) + } + if (targets.length === 1 && rawMessage) { + singleTargetMessage = rawMessage + } + } + if (singleTargetMessage) { + return { + status, + summary: singleTargetMessage + } + } + if (parts.length > 0) { + return { + status, + summary: parts.join(' • ') + } + } + } + + 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 lifecycle = ensureLifecycle(spawn, spawnIdentity.agentId, spawnIdentity.nickname) + lifecycle.hiddenToolIds.push(block.tool.id) + + if (block.tool.name === 'CodexWaitAgent') { + const result = summarizeWaitResult(block, targets) + 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 = targets[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 = targets[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/codexSidechain.test.ts b/web/src/chat/codexSidechain.test.ts new file mode 100644 index 000000000..bed1ae4d8 --- /dev/null +++ b/web/src/chat/codexSidechain.test.ts @@ -0,0 +1,261 @@ +import { describe, expect, it } from 'vitest' +import type { NormalizedMessage } from './types' +import { annotateCodexSidechains } from './codexSidechain' + +function agentToolCall( + id: string, + name: string, + input: unknown, + createdAt: number +): NormalizedMessage { + return { + id: `msg-${id}`, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-call', + id, + name, + input, + description: null, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function agentToolResult( + toolUseId: string, + content: unknown, + createdAt: number +): NormalizedMessage { + return { + id: `msg-${toolUseId}-result`, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'tool-result', + tool_use_id: toolUseId, + content, + is_error: false, + uuid: `uuid-${toolUseId}-result`, + parentUUID: null + }] + } +} + +function agentText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function agentReasoning(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'reasoning', + text, + uuid: `uuid-${id}`, + parentUUID: null + }] + } +} + +function userText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text } + } +} + +describe('annotateCodexSidechains', () => { + it('marks inline child messages under the matching CodexSpawnAgent', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + agentReasoning('child-reasoning', 'child thought', 5), + agentToolCall('child-tool', 'CodexSendInput', { target: 'agent-1', message: 'ping' }, 6), + agentToolResult('child-tool', { ok: true }, 7), + userText('notification', ' done', 8), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 9) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[4]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: false }) + expect(result[8]).toMatchObject({ isSidechain: false }) + }) + + it('keeps messages root-level when the spawn result has no agent_id', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('spawn-1', { nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 5) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: false }) + expect(result[3]).toMatchObject({ isSidechain: false }) + expect(result[4]).toMatchObject({ isSidechain: false }) + }) + + it('binds sequential spawns to the correct spawn key', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + userText('child-1-user', 'first child prompt', 3), + agentText('child-1-agent', 'first child answer', 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 5), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second' }, 6), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 7), + userText('child-2-user', 'second child prompt', 8), + agentText('child-2-agent', 'second child answer', 9), + agentToolCall('wait-2', 'CodexWaitAgent', { targets: ['agent-2'] }, 10) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[8]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[9]).toMatchObject({ isSidechain: false }) + }) + + it('keeps earlier spawned agents active when a newer spawn closes first', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + agentToolCall('wait-2', 'CodexWaitAgent', { targets: ['agent-2'] }, 5), + userText('child-1-user', 'first child prompt resumes', 6), + agentText('child-1-agent', 'first child answer resumes', 7), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 8) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: false }) + }) + + it('marks live inline child messages after a valid spawn call before the spawn result lands', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'Delegate task' }, 1), + userText('child-user', 'live child prompt', 2), + agentText('child-agent', 'live child answer', 3), + agentToolResult('spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 4), + agentToolCall('wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 5) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[1]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[3]).toMatchObject({ isSidechain: false }) + expect(result[4]).toMatchObject({ isSidechain: false }) + }) + + it('does not nest a later CodexSpawnAgent tool call under the currently active child', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: false }) + expect(result[3]).toMatchObject({ isSidechain: false }) + }) + + it('preserves explicit sidechain keys for parallel live child streams', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + { + ...userText('child-1-user', 'first child prompt', 5), + isSidechain: true, + sidechainKey: 'spawn-1' + }, + { + ...userText('child-2-user', 'second child prompt', 6), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + { + ...agentText('child-1-agent', 'first child answer', 7), + isSidechain: true, + sidechainKey: 'spawn-1' + }, + { + ...agentText('child-2-agent', 'second child answer', 8), + isSidechain: true, + sidechainKey: 'spawn-2' + } + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[5]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + expect(result[6]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-1' }) + expect(result[7]).toMatchObject({ isSidechain: true, sidechainKey: 'spawn-2' }) + }) + + it('does not heuristically assign untagged messages when multiple child agents are active', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('spawn-1', { agent_id: 'agent-1' }, 2), + agentToolCall('spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('spawn-2', { agent_id: 'agent-2' }, 4), + agentText('root-parent-progress', 'Parent progress update', 5), + agentText('stray-child-reply', 'First child answer', 6) + ] + + const result = annotateCodexSidechains(messages) + + expect(result[4]).toMatchObject({ isSidechain: false }) + expect(result[5]).toMatchObject({ isSidechain: false }) + }) +}) diff --git a/web/src/chat/codexSidechain.ts b/web/src/chat/codexSidechain.ts new file mode 100644 index 000000000..d09b6f608 --- /dev/null +++ b/web/src/chat/codexSidechain.ts @@ -0,0 +1,162 @@ +import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' +import { isObject } from '@hapi/protocol' + +const SUBAGENT_NOTIFICATION_PREFIX = '' + +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 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)) +} + +export function annotateCodexSidechains(messages: NormalizedMessage[]): NormalizedMessage[] { + // Pass 1: Identify which spawn calls have valid results (with agent_id) + 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) + } + } + } + + // Pass 2: Annotate + const agentIdToSpawnToolUseId = new Map() + let activeAgentIds: string[] = [] + let pendingSpawnToolUseId: string | null = null + + const result: NormalizedMessage[] = [] + + for (const message of messages) { + if (message.isSidechain === true && typeof message.sidechainKey === 'string' && message.sidechainKey.length > 0) { + result.push({ ...message }) + 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 + agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) + 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 + } + + 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 + }) + continue + } + + result.push({ ...message }) + } + + return result +} diff --git a/web/src/chat/normalize.test.ts b/web/src/chat/normalize.test.ts index f6f38a0dd..686d56bab 100644 --- a/web/src/chat/normalize.test.ts +++ b/web/src/chat/normalize.test.ts @@ -13,6 +13,190 @@ 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: 'spawn', + input: { prompt: 'hi' }, + 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 Claude subagent metadata as sidechain flags on agent payloads', () => { + const message = makeMessage({ + role: 'agent', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'assistant', + message: { + content: [{ + type: 'text', + text: 'child answer' + }] + } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + } + }) + }) + + it('preserves Claude sidechain root prompt records with normalized sidechain keys', () => { + const message = makeMessage({ + role: 'agent', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: { + type: 'output', + data: { + type: 'user', + isSidechain: true, + message: { + content: 'Investigate flaky test' + } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: 'Investigate flaky test' + } + }, + content: [ + { + type: 'sidechain', + prompt: 'Investigate flaky test' + } + ] + }) + }) + + it('keeps normal Codex payloads root-level when parentToolCallId is absent', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'spawn', + input: { prompt: 'hi' } + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: false + }) + expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() + }) + + it('keeps Codex payloads root-level when parentToolCallId is present without isSidechain', () => { + const message = makeMessage({ + role: 'agent', + content: { + type: 'codex', + data: { + type: 'tool-call', + callId: 'tool-call-1', + id: 'tool-use-1', + name: 'spawn', + input: { prompt: 'hi' }, + parentToolCallId: 'spawn-1' + } + } + }) + + expect(normalizeDecryptedMessage(message)).toMatchObject({ + id: 'msg-1', + role: 'agent', + isSidechain: false + }) + expect(normalizeDecryptedMessage(message)?.sidechainKey).toBeUndefined() + }) + 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 18886e989..b17b9e0ca 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 @@ -128,6 +170,8 @@ function normalizeUserOutput( createdAt, role: 'agent', isSidechain: true, + ...(sidechainKey ? { sidechainKey } : {}), + meta, content: [{ type: 'sidechain', uuid, prompt: messageContent }] } } @@ -314,6 +358,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 { @@ -321,7 +366,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'text', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -333,7 +379,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'reasoning', text: data.message, uuid: messageId, parentUUID: null }], meta } @@ -346,7 +393,8 @@ export function normalizeAgentRecord( localId, createdAt, role: 'agent', - isSidechain: false, + isSidechain, + ...(sidechainKey ? { sidechainKey } : {}), content: [{ type: 'tool-call', id: data.callId, @@ -367,7 +415,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 3785c8f61..15a87815f 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/reducer.test.ts b/web/src/chat/reducer.test.ts new file mode 100644 index 000000000..ed4ab6302 --- /dev/null +++ b/web/src/chat/reducer.test.ts @@ -0,0 +1,431 @@ +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): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'user', + isSidechain: false, + content: { type: 'text', text } + } +} + +function agentText(id: string, text: string, createdAt: number): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: false, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +function agentMessage( + id: string, + createdAt: number, + content: NormalizedAgentContent[], + extra: Partial> = {} +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: extra.isSidechain ?? false, + ...(extra.sidechainKey ? { sidechainKey: extra.sidechainKey } : {}), + ...(extra.meta ? { meta: extra.meta } : {}), + content + } +} + +describe('reduceChatBlocks', () => { + it('groups Codex child messages under the matching spawn tool block and folds lifecycle controls into it', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-call', 'spawn-1', 'CodexSpawnAgent', { message: 'Search GitHub trending' }, 1), + agentToolResult('msg-spawn-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'Pauli' }, 2), + userText('child-user', 'child prompt', 3), + agentText('child-agent', 'child answer', 4), + userText('notification', ' child update', 5), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'], timeout_ms: 120000 }, 6), + agentToolResult('msg-wait-result', 'wait-1', { status: 'completed', text: 'agent finished' }, 7), + agentToolCall('msg-send-call', 'send-1', 'CodexSendInput', { target: 'agent-1', message: 'continue', interrupt: true }, 8), + agentToolResult('msg-send-result', 'send-1', { ok: true }, 9), + agentToolCall('msg-close-call', 'close-1', 'CodexCloseAgent', { target: 'agent-1' }, 10), + agentToolResult('msg-close-result', 'close-1', { status: 'closed' }, 11) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlock).toBeDefined() + expect(spawnBlock?.children.map((child) => child.kind)).toEqual(['user-text', 'agent-text']) + expect(spawnBlock?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + expect(spawnBlock?.lifecycle).toEqual( + expect.objectContaining({ + kind: 'codex-agent-lifecycle', + agentId: 'agent-1', + nickname: 'Pauli', + status: 'completed' + }) + ) + expect(spawnBlock?.lifecycle?.actions.map((action) => action.type)).toEqual(['wait', 'send', 'close']) + expect(spawnBlock?.lifecycle?.actions.map((action) => action.summary)).toEqual([ + 'agent finished', + 'Sent input to agent-1', + 'Closed agent-1' + ]) + expect(spawnBlock?.lifecycle?.hiddenToolIds).toEqual(expect.arrayContaining(['wait-1', 'send-1', 'close-1'])) + expect( + reduced.blocks.some((block) => block.kind === 'user-text' && block.text === 'child prompt') + ).toBe(false) + expect( + reduced.blocks.some((block) => block.kind === 'agent-text' && block.text === 'child answer') + ).toBe(false) + expect( + reduced.blocks.some((block) => + block.kind === 'tool-call' + && ['CodexWaitAgent', 'CodexSendInput', 'CodexCloseAgent'].includes(block.tool.name) + ) + ).toBe(false) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: ' child update' }), + expect.objectContaining({ kind: 'tool-call', tool: expect.objectContaining({ name: 'CodexSpawnAgent' }) }) + ]) + ) + }) + + it('does not mark unresolved sibling spawn blocks completed from a partial multi-target wait result', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child' }, 1), + agentToolResult('msg-spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('msg-spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child' }, 3), + agentToolResult('msg-spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 5), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'done' + } + } + }, 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?.status).toBe('completed') + expect(spawnBlocks.find((block) => block.tool.id === 'spawn-2')?.lifecycle?.status).toBe('running') + }) + + it('reassigns a stray root child reply to the matching spawn card using wait status messages', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-1-call', 'spawn-1', 'CodexSpawnAgent', { message: 'First child prompt' }, 1), + agentToolResult('msg-spawn-1-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'First' }, 2), + agentToolCall('msg-spawn-2-call', 'spawn-2', 'CodexSpawnAgent', { message: 'Second child prompt' }, 3), + agentToolResult('msg-spawn-2-result', 'spawn-2', { agent_id: 'agent-2', nickname: 'Second' }, 4), + { + ...userText('child-2-user', 'Second child prompt', 5), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + { + ...agentText('child-2-agent', 'Second child answer', 6), + isSidechain: true, + sidechainKey: 'spawn-2' + }, + agentText('child-1-root-agent', 'First child answer', 7), + agentText('parent-agent', 'Parent progress update', 8), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1', 'agent-2'] }, 9), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'First child answer' + }, + 'agent-2': { + status: 'completed', + message: 'Second child answer' + } + } + }, 10) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlocks = reduced.blocks.filter( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + const firstSpawn = spawnBlocks.find((block) => block.tool.id === 'spawn-1') + const secondSpawn = spawnBlocks.find((block) => block.tool.id === 'spawn-2') + + expect(firstSpawn?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'First child answer' }) + ]) + ) + expect(secondSpawn?.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 === 'Parent progress update') + ).toBe(true) + }) + + it('uses the completed child message as lifecycle latest text for single-target waits', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-spawn-call', 'spawn-1', 'CodexSpawnAgent', { message: 'Delegate task' }, 1), + agentToolResult('msg-spawn-result', 'spawn-1', { agent_id: 'agent-1', nickname: 'Solo' }, 2), + agentToolCall('msg-wait-call', 'wait-1', 'CodexWaitAgent', { targets: ['agent-1'] }, 3), + agentToolResult('msg-wait-result', 'wait-1', { + statuses: { + 'agent-1': { + status: 'completed', + message: 'Final child answer' + } + } + }, 4) + ] + + const reduced = reduceChatBlocks(messages, null) + const spawnBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.name === 'CodexSpawnAgent' + ) + + expect(spawnBlock?.lifecycle?.latestText).toBe('Final child answer') + }) + + it('groups Claude sidechain messages under the parent Task tool call', () => { + const messages: NormalizedMessage[] = [ + agentMessage('msg-parent', 1, [{ + type: 'tool-call', + id: 'other-tool', + name: 'OtherTool', + input: { prompt: 'ignore me' }, + description: null, + uuid: 'msg-parent-uuid', + parentUUID: null + }, { + type: 'tool-call', + id: 'task-1', + name: 'Task', + input: { prompt: 'Investigate flaky test' }, + description: null, + uuid: 'msg-parent-uuid', + parentUUID: null + }]), + { + ...userText('child-user', 'child prompt', 2), + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-1' + } + } + }, + { + ...agentText('child-agent', 'child answer', 3), + isSidechain: true, + sidechainKey: 'task-1', + meta: { + subagent: { + kind: 'message', + 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' + ) + const otherBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'other-tool' + ) + + expect(taskBlock?.children).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + expect(otherBlock?.children).toHaveLength(0) + }) + + it('keeps explicit sidechain transcripts visible when the parent card is missing from the current slice', () => { + const messages: NormalizedMessage[] = [ + { + ...userText('child-user', 'root prompt', 1), + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-missing' + } + } + }, + { + ...agentText('child-agent', 'child reply', 2), + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'message', + sidechainKey: 'task-missing' + } + } + } + ] + + const reduced = reduceChatBlocks(messages, null) + const parentBlock = reduced.blocks.find( + (block): block is ToolCallBlock => block.kind === 'tool-call' && block.tool.id === 'task-missing' + ) + + expect(parentBlock).toBeUndefined() + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'user-text', text: 'root prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child reply' }) + ]) + ) + }) + + it('renders a root-only explicit sidechain prompt when no parent card exists in the current slice', () => { + const messages: NormalizedMessage[] = [ + { + id: 'msg-root-prompt', + localId: null, + createdAt: 1, + role: 'agent', + isSidechain: true, + sidechainKey: 'task-missing', + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-missing', + prompt: 'Investigate flaky test' + } + }, + content: [{ + type: 'sidechain', + uuid: 'msg-root-prompt-uuid', + prompt: 'Investigate flaky test' + }] + } + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks).toEqual([ + expect.objectContaining({ + kind: 'user-text', + text: 'Investigate flaky test' + }) + ]) + }) + + it('keeps preserved sidechain blocks in chronological order with root blocks', () => { + const messages: NormalizedMessage[] = [ + { + id: 'msg-root-prompt', + localId: null, + createdAt: 1, + role: 'agent', + isSidechain: true, + sidechainKey: 'task-missing', + content: [{ + type: 'sidechain', + uuid: 'msg-root-prompt-uuid', + prompt: 'Investigate flaky test' + }] + }, + agentText('root-update', 'Parent progress update', 2) + ] + + const reduced = reduceChatBlocks(messages, null) + + expect(reduced.blocks.map((block) => block.kind)).toEqual(['user-text', 'agent-text']) + expect(reduced.blocks[0]).toMatchObject({ kind: 'user-text', text: 'Investigate flaky test' }) + expect(reduced.blocks[1]).toMatchObject({ kind: 'agent-text', text: 'Parent progress update' }) + }) +}) diff --git a/web/src/chat/reducer.ts b/web/src/chat/reducer.ts index 798499c67..efabbea18 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 1d40714e1..a2f82d0cd 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -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[] = [] @@ -173,10 +176,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 @@ -240,7 +243,16 @@ export function reduceTimeline( } if (c.type === 'sidechain') { - // Skip - the prompt is already visible in the parent Task tool call's input + if (options?.renderSidechainPromptAsUserText) { + blocks.push({ + kind: 'user-text', + id: `${msg.id}:${idx}`, + localId: msg.localId, + createdAt: msg.createdAt, + text: c.prompt, + meta: msg.meta + }) + } continue } } diff --git a/web/src/chat/subagentSidechain.test.ts b/web/src/chat/subagentSidechain.test.ts new file mode 100644 index 000000000..636cd53a9 --- /dev/null +++ b/web/src/chat/subagentSidechain.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from 'vitest' +import { annotateSubagentSidechains } from './subagentSidechain' +import type { NormalizedMessage } 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 childAgentMessage( + id: string, + text: string, + createdAt: number, + sidechainKey: string +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + sidechainKey, + meta: { + subagent: { + kind: 'message', + sidechainKey + } + }, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +describe('annotateSubagentSidechains', () => { + it('preserves Claude sidechain keys that point at the Task tool-use id', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-parent', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + childAgentMessage('child-user', 'child prompt', 2, 'task-1'), + childAgentMessage('child-agent', 'child answer', 3, 'task-1') + ] + + const result = annotateSubagentSidechains(messages) + + expect(result[1]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + }) + + it('does not rewrite explicit Claude sidechain keys to the enclosing message id when multiple tool calls exist', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-parent', 'other-tool', 'OtherTool', { prompt: 'ignore' }, 1), + agentToolCall('msg-parent', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + childAgentMessage('child-user', 'child prompt', 2, 'task-1') + ] + + const result = annotateSubagentSidechains(messages) + + expect(result[2]).toMatchObject({ isSidechain: true, sidechainKey: 'task-1' }) + expect(result[2]).not.toMatchObject({ sidechainKey: 'msg-parent' }) + }) +}) diff --git a/web/src/chat/subagentSidechain.ts b/web/src/chat/subagentSidechain.ts new file mode 100644 index 000000000..3befe772f --- /dev/null +++ b/web/src/chat/subagentSidechain.ts @@ -0,0 +1,195 @@ +import type { NormalizedAgentContent, NormalizedMessage } from '@/chat/types' +import { isObject } from '@hapi/protocol' + +const SUBAGENT_NOTIFICATION_PREFIX = '' + +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 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 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: NormalizedMessage[]): NormalizedMessage[] { + // Pass 1: Identify which spawn calls have valid results (with agent_id) + 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) + } + } + } + + // Pass 2: Annotate + const agentIdToSpawnToolUseId = new Map() + let activeAgentIds: string[] = [] + let pendingSpawnToolUseId: string | null = null + + const result: NormalizedMessage[] = [] + + for (const message of messages) { + const explicit = annotateExplicitSidechain(message) + if (explicit) { + result.push(explicit) + 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 + agentIdToSpawnToolUseId.set(spawn.agentId, spawn.spawnToolUseId) + 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 + } + + 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 + }) + continue + } + + result.push({ ...message }) + } + + return result +} diff --git a/web/src/chat/tracer.test.ts b/web/src/chat/tracer.test.ts new file mode 100644 index 000000000..21ba009b2 --- /dev/null +++ b/web/src/chat/tracer.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, it } from 'vitest' +import { reduceChatBlocks } from './reducer' +import type { NormalizedMessage } 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 sidechainMessage( + id: string, + text: string, + createdAt: number +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'sidechain', + uuid: `${id}-uuid`, + prompt: text + }] + } +} + +function sidechainText( + id: string, + text: string, + createdAt: number +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID: null + }] + } +} + +function sidechainTextWithParent( + id: string, + text: string, + createdAt: number, + parentUUID: string +): NormalizedMessage { + return { + id, + localId: null, + createdAt, + role: 'agent', + isSidechain: true, + content: [{ + type: 'text', + text, + uuid: `${id}-uuid`, + parentUUID + }] + } +} + +describe('traceMessages sidechain fallback', () => { + it('does not attach ambiguous duplicate Task prompts to the later task', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-task-1', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + agentToolCall('msg-task-2', 'task-2', 'Task', { prompt: 'Investigate flaky test' }, 2), + sidechainMessage('msg-root', 'Investigate flaky test', 3), + sidechainText('msg-child-user', 'child prompt', 4), + sidechainText('msg-child-agent', 'child answer', 5) + ] + + const reduced = reduceChatBlocks(messages, null) + const task1 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-1') + const task2 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-2') + + expect(task1?.children).toHaveLength(0) + expect(task2?.children).toHaveLength(0) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + }) + + it('keeps ambiguous unresolved sidechain descendants visible at the root level', () => { + const messages: NormalizedMessage[] = [ + agentToolCall('msg-task-1', 'task-1', 'Task', { prompt: 'Investigate flaky test' }, 1), + agentToolCall('msg-task-2', 'task-2', 'Task', { prompt: 'Investigate flaky test' }, 2), + sidechainMessage('msg-root', 'Investigate flaky test', 3), + sidechainTextWithParent('msg-child-user', 'child prompt', 4, 'msg-root-uuid'), + sidechainTextWithParent('msg-child-agent', 'child answer', 5, 'msg-child-user-uuid') + ] + + const reduced = reduceChatBlocks(messages, null) + const task1 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-1') + const task2 = reduced.blocks.find((block): block is Extract<(typeof reduced.blocks)[number], { kind: 'tool-call' }> => block.kind === 'tool-call' && block.tool.id === 'task-2') + + expect(task1?.children).toHaveLength(0) + expect(task2?.children).toHaveLength(0) + expect(reduced.blocks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ kind: 'agent-text', text: 'child prompt' }), + expect.objectContaining({ kind: 'agent-text', text: 'child answer' }) + ]) + ) + }) +}) diff --git a/web/src/chat/tracer.ts b/web/src/chat/tracer.ts index db3981c81..50eac34c9 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 fbaa5b417..201e8abc0 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -80,6 +80,7 @@ export type NormalizedMessage = ({ localId: string | null createdAt: number isSidechain: boolean + sidechainKey?: string meta?: unknown usage?: UsageData status?: MessageStatus @@ -99,6 +100,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 @@ -167,6 +186,7 @@ export type ToolCallBlock = { createdAt: number tool: ChatToolCall children: ChatBlock[] + lifecycle?: CodexAgentLifecycle meta?: unknown } diff --git a/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx b/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx new file mode 100644 index 000000000..1a502b9d7 --- /dev/null +++ b/web/src/components/AssistantChat/AttachmentPickerButton.test.tsx @@ -0,0 +1,68 @@ +import { fireEvent, render } from '@testing-library/react' +import { describe, expect, it, vi } from 'vitest' +import { AttachmentPickerButton } from './AttachmentPickerButton' + +describe('AttachmentPickerButton', () => { + it('uses an image picker on touch devices', () => { + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') + expect(input).not.toBeNull() + expect(input?.getAttribute('accept')).toBe('image/*') + }) + + it('keeps the generic picker on non-touch devices', () => { + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') + expect(input).not.toBeNull() + expect(input?.getAttribute('accept')).toBeNull() + }) + + it('forwards selected files and clears the input value', async () => { + const onFilesSelected = vi.fn().mockResolvedValue(undefined) + const { container } = render( + + ) + + const input = container.querySelector('input[type="file"]') as HTMLInputElement | null + expect(input).not.toBeNull() + const file = new File(['photo'], 'photo.jpg', { type: 'image/jpeg' }) + + Object.defineProperty(input, 'files', { + configurable: true, + value: [file] + }) + Object.defineProperty(input, 'value', { + configurable: true, + writable: true, + value: 'C:\\fakepath\\photo.jpg' + }) + + fireEvent.change(input as HTMLInputElement) + + await vi.waitFor(() => { + expect(onFilesSelected).toHaveBeenCalledWith([file]) + }) + expect(input?.value).toBe('') + }) +}) diff --git a/web/src/components/AssistantChat/AttachmentPickerButton.tsx b/web/src/components/AssistantChat/AttachmentPickerButton.tsx new file mode 100644 index 000000000..4ebb17e60 --- /dev/null +++ b/web/src/components/AssistantChat/AttachmentPickerButton.tsx @@ -0,0 +1,81 @@ +import type { ChangeEvent, KeyboardEvent } from 'react' +import { useCallback, useId, useRef } from 'react' + +function AttachmentIcon() { + return ( + + + + ) +} + +export function AttachmentPickerButton(props: { + label: string + disabled: boolean + isTouch: boolean + onFilesSelected: (files: File[]) => void | Promise +}) { + const inputId = useId() + const inputRef = useRef(null) + const accept = props.isTouch ? 'image/*' : undefined + + const handleChange = useCallback(async (event: ChangeEvent) => { + const files = Array.from(event.target.files ?? []) + event.target.value = '' + if (files.length === 0) { + return + } + await props.onFilesSelected(files) + }, [props]) + + const handleKeyDown = useCallback((event: KeyboardEvent) => { + if (props.disabled) { + return + } + if (event.key !== 'Enter' && event.key !== ' ') { + return + } + event.preventDefault() + inputRef.current?.click() + }, [props.disabled]) + + return ( + <> + + + + ) +} diff --git a/web/src/components/AssistantChat/ComposerButtons.tsx b/web/src/components/AssistantChat/ComposerButtons.tsx index 2777c9056..a85231331 100644 --- a/web/src/components/AssistantChat/ComposerButtons.tsx +++ b/web/src/components/AssistantChat/ComposerButtons.tsx @@ -1,6 +1,6 @@ -import { ComposerPrimitive } from '@assistant-ui/react' import type { ConversationStatus } from '@/realtime/types' import { useTranslation } from '@/lib/use-translation' +import type { ReactNode } from 'react' function VoiceAssistantIcon() { return ( @@ -125,24 +125,6 @@ function TerminalIcon() { ) } -function AttachmentIcon() { - return ( - - - - ) -} - function AbortIcon(props: { spinning: boolean }) { if (props.spinning) { return ( @@ -318,6 +300,7 @@ export function ComposerButtons(props: { voiceMicMuted?: boolean onVoiceToggle: () => void onVoiceMicToggle?: () => void + attachmentControl: ReactNode onSend: () => void }) { const { t } = useTranslation() @@ -326,14 +309,7 @@ export function ComposerButtons(props: { return (
- - - + {props.attachmentControl} {props.showSettingsButton ? ( +
+ ) +} + const defaultSuggestionHandler = async (): Promise => [] export function HappyComposer(props: { + apiClient: ApiClient + sessionId: string disabled?: boolean permissionMode?: PermissionMode collaborationMode?: CodexCollaborationMode @@ -55,6 +117,9 @@ export function HappyComposer(props: { onPermissionModeChange?: (mode: PermissionMode) => void onModelChange?: (model: string | null) => void onEffortChange?: (effort: string | null) => void + onSendMessage?: (text: string, attachments?: AttachmentMetadata[]) => void + resolveSessionId?: (sessionId: string) => Promise + onSessionResolved?: (sessionId: string) => void onSwitchToRemote?: () => void onTerminal?: () => void terminalUnsupported?: boolean @@ -69,6 +134,8 @@ export function HappyComposer(props: { const { t } = useTranslation() const { disabled = false, + apiClient, + sessionId, permissionMode: rawPermissionMode, collaborationMode: rawCollaborationMode, model: rawModel, @@ -84,6 +151,9 @@ export function HappyComposer(props: { onPermissionModeChange, onModelChange, onEffortChange, + onSendMessage, + resolveSessionId, + onSessionResolved, onSwitchToRemote, onTerminal, terminalUnsupported = false, @@ -103,24 +173,15 @@ export function HappyComposer(props: { const api = useAssistantApi() const composerText = useAssistantState(({ composer }) => composer.text) - const attachments = useAssistantState(({ composer }) => composer.attachments) const threadIsRunning = useAssistantState(({ thread }) => thread.isRunning) const threadIsDisabled = useAssistantState(({ thread }) => thread.isDisabled) const controlsDisabled = disabled || (!active && !allowSendWhenInactive) || threadIsDisabled const trimmed = composerText.trim() const hasText = trimmed.length > 0 + const [attachments, setAttachments] = useState([]) const hasAttachments = attachments.length > 0 - const attachmentsReady = !hasAttachments || attachments.every((attachment) => { - if (attachment.status.type === 'complete') { - return true - } - if (attachment.status.type !== 'requires-action') { - return false - } - const path = (attachment as { path?: string }).path - return typeof path === 'string' && path.length > 0 - }) + const attachmentsReady = attachments.every((attachment) => attachment.status === 'ready' && attachment.path.length > 0) const canSend = (hasText || hasAttachments) && attachmentsReady && !controlsDisabled && !threadIsRunning const [inputState, setInputState] = useState({ @@ -278,6 +339,22 @@ export function HappyComposer(props: { [permissionModeOptions] ) + const performSend = useCallback(() => { + if (!attachmentsReady || !canSend || !onSendMessage) { + return + } + + const readyAttachments = attachments + .filter((attachment): attachment is LocalComposerAttachment & { status: 'ready' } => attachment.status === 'ready') + .map(({ status: _status, uploadSessionId: _uploadSessionId, error: _error, ...metadata }) => metadata) + + onSendMessage(composerText, readyAttachments.length > 0 ? readyAttachments : undefined) + api.composer().setText('') + setInputState({ text: '', selection: { start: 0, end: 0 } }) + setAttachments([]) + setShowContinueHint(false) + }, [api, attachments, attachmentsReady, canSend, composerText, onSendMessage]) + const handleKeyDown = useCallback((e: ReactKeyboardEvent) => { const key = e.key @@ -290,8 +367,7 @@ export function HappyComposer(props: { if (key === 'Enter' && e.shiftKey) { e.preventDefault() if (!canSend) return - api.composer().send() - setShowContinueHint(false) + performSend() return } @@ -346,7 +422,7 @@ export function HappyComposer(props: { permissionMode, permissionModes, canSend, - api, + performSend, haptic ]) @@ -379,6 +455,53 @@ export function HappyComposer(props: { })) }, []) + const handleAttachmentFilesSelected = useCallback(async (files: File[]) => { + for (const file of files) { + const pendingId = makeClientSideId('attachment') + setAttachments((prev) => [ + ...prev, + { + id: pendingId, + filename: file.name, + mimeType: file.type || 'application/octet-stream', + size: file.size, + path: '', + previewUrl: undefined, + status: 'uploading' + } + ]) + + try { + const result = await uploadAttachmentFile({ + api: apiClient, + sessionId, + file, + options: { + resolveSessionId, + onSessionResolved + } + }) + setAttachments((prev) => prev.map((attachment) => ( + attachment.id === pendingId + ? { + ...result.metadata, + id: pendingId, + status: 'ready', + uploadSessionId: result.sessionId + } + : attachment + ))) + } catch (error) { + const message = formatAttachmentUploadError(error) + setAttachments((prev) => prev.map((attachment) => ( + attachment.id === pendingId + ? { ...attachment, status: 'error', error: message } + : attachment + ))) + } + } + }, [apiClient, onSessionResolved, resolveSessionId, sessionId]) + const handlePaste = useCallback(async (e: ReactClipboardEvent) => { const files = Array.from(e.clipboardData?.files || []) const imageFiles = files.filter(file => file.type.startsWith('image/')) @@ -386,15 +509,25 @@ export function HappyComposer(props: { if (imageFiles.length === 0) return e.preventDefault() + await handleAttachmentFilesSelected(imageFiles) + }, [handleAttachmentFilesSelected]) + const handleRemoveAttachment = useCallback(async (attachmentId: string) => { + const attachment = attachments.find((item) => item.id === attachmentId) + setAttachments((prev) => prev.filter((item) => item.id !== attachmentId)) + if (!attachment?.path || !attachment.uploadSessionId) { + return + } try { - for (const file of imageFiles) { - await api.composer().addAttachment(file) - } + await deleteUploadedAttachment({ + api: apiClient, + sessionId: attachment.uploadSessionId, + path: attachment.path + }) } catch (error) { - console.error('Error adding pasted image:', error) + console.error('Error deleting attachment:', error) } - }, [api]) + }, [apiClient, attachments]) const handleSettingsToggle = useCallback(() => { haptic('light') @@ -402,12 +535,9 @@ export function HappyComposer(props: { }, [haptic]) const handleSubmit = useCallback((event?: ReactFormEvent) => { - if (event && !attachmentsReady) { - event.preventDefault() - return - } - setShowContinueHint(false) - }, [attachmentsReady]) + event?.preventDefault() + performSend() + }, [performSend]) const handlePermissionChange = useCallback((mode: PermissionMode) => { if (!onPermissionModeChange || controlsDisabled) return @@ -446,8 +576,8 @@ export function HappyComposer(props: { const voiceEnabled = Boolean(onVoiceToggle) const handleSend = useCallback(() => { - api.composer().send() - }, [api]) + performSend() + }, [performSend]) const overlays = useMemo(() => { if (showSettings && (showCollaborationSettings || showPermissionSettings || showModelSettings || showEffortSettings)) { @@ -679,7 +809,15 @@ export function HappyComposer(props: {
{attachments.length > 0 ? (
- + {attachments.map((attachment) => ( + { + void handleRemoveAttachment(attachment.id) + }} + /> + ))}
) : null} @@ -696,6 +834,7 @@ export function HappyComposer(props: { onSelect={handleSelect} onKeyDown={handleKeyDown} onPaste={handlePaste} + addAttachmentOnPaste={false} className="flex-1 resize-none bg-transparent text-base leading-snug text-[var(--app-fg)] placeholder-[var(--app-hint)] focus:outline-none disabled:cursor-not-allowed disabled:opacity-50" />
@@ -703,6 +842,14 @@ export function HappyComposer(props: { + )} showSettingsButton={showSettingsButton} onSettingsToggle={handleSettingsToggle} showTerminalButton={showTerminalButton} diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx new file mode 100644 index 000000000..9c8d771ea --- /dev/null +++ b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.test.tsx @@ -0,0 +1,373 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactElement } from 'react' +import type { ToolCallBlock } from '@/chat/types' +import { CodexSubagentPreviewCard } from '@/components/AssistantChat/messages/CodexSubagentPreviewCard' +import { getToolChildRenderMode } from '@/components/AssistantChat/messages/ToolMessage' +import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { I18nProvider } from '@/lib/i18n-context' + +vi.mock('@/components/MarkdownRenderer', () => ({ + MarkdownRenderer: ({ content }: { content: string }) => { + const linkMatch = content.match(/\[([^\]]+)\]\(([^)]+)\)/) + if (linkMatch) { + return {linkMatch[1]} + } + return
{content}
+ } +})) + +function makeSpawnBlock(): ToolCallBlock { + const delegatedPrompt = 'Search GitHub trending repositories for React state tooling' + + return { + kind: 'tool-call', + id: 'spawn-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'spawn-1', + name: 'CodexSpawnAgent', + state: 'completed', + input: { + message: delegatedPrompt, + model: 'gpt-5.4-mini' + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null, + result: { + agent_id: 'agent-1', + nickname: 'Pauli' + } + }, + lifecycle: { + kind: 'codex-agent-lifecycle', + agentId: 'agent-1', + nickname: 'Pauli', + status: 'waiting', + latestText: 'Waiting for child agent to finish', + hiddenToolIds: ['wait-1'], + actions: [ + { type: 'wait', createdAt: 4, summary: 'Waiting for child agent to finish' } + ] + }, + children: [ + { + kind: 'user-text', + id: 'child-user-1', + localId: null, + createdAt: 3, + text: delegatedPrompt, + meta: undefined + }, + { + kind: 'agent-text', + id: 'child-agent-1', + localId: null, + createdAt: 4, + text: 'See [repo](https://github.com/example/repo)', + meta: undefined + } + ] + } +} + +function makeTaskBlock(): ToolCallBlock { + const delegatedPrompt = 'Investigate flaky Task sidechain rendering' + + return { + kind: 'tool-call', + id: 'task-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'task-1', + name: 'Task', + state: 'completed', + input: { + prompt: delegatedPrompt + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-1', + prompt: delegatedPrompt + } + }, + children: [ + { + kind: 'user-text', + id: 'task-child-user-1', + localId: null, + createdAt: 2, + text: delegatedPrompt, + meta: undefined + }, + { + kind: 'agent-text', + id: 'task-child-agent-1', + localId: null, + createdAt: 3, + text: 'Task child answer', + meta: undefined + } + ] + } +} + +function makeTaskHybridBlock(): ToolCallBlock { + const delegatedPrompt = 'Investigate flaky Task sidechain rendering' + + return { + kind: 'tool-call', + id: 'task-block-2', + localId: null, + createdAt: 1, + tool: { + id: 'task-2', + name: 'Task', + state: 'completed', + input: { + prompt: delegatedPrompt + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + meta: { + subagent: { + kind: 'spawn', + sidechainKey: 'task-2', + prompt: delegatedPrompt + } + }, + children: [ + { + kind: 'tool-call', + id: 'task-pending-child', + localId: null, + createdAt: 2, + tool: { + id: 'pending-1', + name: 'Bash', + state: 'pending', + input: { + command: ['echo', 'pending child'] + }, + createdAt: 2, + startedAt: null, + completedAt: null, + description: null, + permission: { + id: 'pending-approval-1', + status: 'pending' + } + }, + children: [] + }, + { + kind: 'agent-text', + id: 'task-child-agent-1', + localId: null, + createdAt: 3, + text: 'Task child answer', + meta: undefined + } + ] + } +} + +function renderWithProviders(ui: ReactElement) { + if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) + } + + return render( + + {} + }} + > + {ui} + + + ) +} + +afterEach(() => { + cleanup() +}) + +describe('CodexSubagentPreviewCard', () => { + it('keeps child transcript hidden until opened, then shows it in dialog', () => { + const block = makeSpawnBlock() + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Waiting')).toBeInTheDocument() + expect(screen.getByText(/Pauli/)).toBeInTheDocument() + expect(screen.queryByText(/agent-1/i)).not.toBeInTheDocument() + expect(screen.getByText(/Waiting for child agent to finish/)).toBeInTheDocument() + expect(screen.queryByText('See [repo](https://github.com/example/repo)')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + + expect(screen.getByRole('link', { name: 'repo' })).toHaveAttribute('href', 'https://github.com/example/repo') + expect(screen.getByRole('button', { name: 'Close dialog' })).toBeInTheDocument() + expect(screen.getAllByText('Search GitHub trending repositories for React state tooling').length).toBeGreaterThan(0) + }) + + it('renders HappyToolMessage as the lifecycle card for CodexSpawnAgent', () => { + const block = makeSpawnBlock() + const props: any = { + artifact: block, + toolName: 'CodexSpawnAgent', + argsText: '{}', + result: block.tool.result, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Waiting')).toBeInTheDocument() + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() + expect(screen.queryByText('Search GitHub trending repositories for React state tooling')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + + expect(screen.getAllByText('Search GitHub trending repositories for React state tooling').length).toBeGreaterThan(0) + expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() + }) + + it('renders HappyToolMessage as the lifecycle card for Claude Task sidechains while keeping pending children inline', () => { + const block = makeTaskHybridBlock() + const props: any = { + artifact: block, + toolName: 'Task', + argsText: '{}', + result: undefined, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.getByText('Subagent conversation')).toBeInTheDocument() + expect(screen.getByText('Completed')).toBeInTheDocument() + expect(screen.getAllByText('Investigate flaky Task sidechain rendering')).toHaveLength(1) + expect(screen.getByText('Waiting for approval…')).toBeInTheDocument() + expect(screen.queryByText('Task child answer')).not.toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation/i })) + + expect(screen.getByText('Task child answer')).toBeInTheDocument() + expect(screen.getAllByText('Waiting for approval…')).toHaveLength(1) + expect(screen.getAllByText('Investigate flaky Task sidechain rendering').length).toBeGreaterThan(0) + }) + + it('closes the dialog via the top close icon button', () => { + const block = makeSpawnBlock() + + renderWithProviders() + + fireEvent.click(screen.getByRole('button', { name: /Subagent conversation — Pauli/i })) + expect(screen.getByRole('link', { name: 'repo' })).toBeInTheDocument() + + fireEvent.click(screen.getByRole('button', { name: 'Close dialog' })) + + expect(screen.queryByRole('link', { name: 'repo' })).not.toBeInTheDocument() + }) + + it('marks CodexSpawnAgent children for preview rendering instead of inline expansion', () => { + const block = makeSpawnBlock() + expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') + }) + + it('marks Task children for preview rendering instead of inline expansion', () => { + const block = makeTaskBlock() + expect(getToolChildRenderMode(block)).toBe('codex-subagent-preview') + }) + + it('keeps ordinary tool children inline instead of using the subagent preview card', () => { + const block: ToolCallBlock = { + kind: 'tool-call', + id: 'bash-block-1', + localId: null, + createdAt: 1, + tool: { + id: 'bash-1', + name: 'Bash', + state: 'completed', + input: { + command: ['echo', 'ordinary child'] + }, + createdAt: 1, + startedAt: 1, + completedAt: 2, + description: null + }, + children: [ + { + kind: 'agent-text', + id: 'bash-child-1', + localId: null, + createdAt: 2, + text: 'ordinary child transcript', + meta: undefined + } + ] + } + + expect(getToolChildRenderMode(block)).toBe('inline') + + const props: any = { + artifact: block, + toolName: 'Bash', + argsText: '{}', + result: undefined, + isError: false, + status: { type: 'complete' } + } + + renderWithProviders( + + ) + + expect(screen.queryByText('Subagent conversation')).not.toBeInTheDocument() + expect(screen.getByText('ordinary child transcript')).toBeInTheDocument() + }) +}) diff --git a/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx b/web/src/components/AssistantChat/messages/CodexSubagentPreviewCard.tsx new file mode 100644 index 000000000..863aa55dd --- /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.tsx b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx new file mode 100644 index 000000000..f0734e2c7 --- /dev/null +++ b/web/src/components/AssistantChat/messages/SubagentPreviewCard.tsx @@ -0,0 +1,357 @@ +import { useMemo, useState, type ReactNode } 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 + subtitle: string | null + 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 subtitle = nickname && nickname.length > 0 ? nickname : null + const countLabel = `${block.children.length} nested block${block.children.length === 1 ? '' : 's'}` + + return { + title: 'Subagent conversation', + subtitle, + 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' +} + +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.subtitle ? `${summary.title} — ${summary.subtitle}` : summary.title + 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 ca1c1f613..36c802db8 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,80 @@ 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' || 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 +248,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/NewSession/ImportExistingModal.test.tsx b/web/src/components/NewSession/ImportExistingModal.test.tsx new file mode 100644 index 000000000..4c8649d5f --- /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 000000000..2f49e5e03 --- /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 000000000..d8c40bd81 --- /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 e0d1dd1c1..dd8f8c260 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/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c..42a7fe1c6 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -13,6 +13,7 @@ type SessionActionMenuProps = { isOpen: boolean onClose: () => void sessionActive: boolean + onShowInfo?: () => void onRename: () => void onArchive: () => void onDelete: () => void @@ -84,6 +85,27 @@ function TrashIcon(props: { className?: string }) { ) } +function InfoIcon(props: { className?: string }) { + return ( + + + + + + ) +} + type MenuPosition = { top: number left: number @@ -96,6 +118,7 @@ export function SessionActionMenu(props: SessionActionMenuProps) { isOpen, onClose, sessionActive, + onShowInfo, onRename, onArchive, onDelete, @@ -113,6 +136,11 @@ export function SessionActionMenu(props: SessionActionMenuProps) { onRename() } + const handleShowInfo = () => { + onClose() + onShowInfo?.() + } + const handleArchive = () => { onClose() onArchive() @@ -229,6 +257,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { aria-labelledby={headingId} className="flex flex-col gap-1" > + {onShowInfo ? ( + + ) : null} + + ) : null} + + + ) +} + +export function SessionInfoDialog(props: { + session: Session + open: boolean + onClose: () => void +}) { + const { t } = useTranslation() + const sourceSessionId = getSourceSessionId(props.session) + const agent = props.session.metadata?.flavor?.trim() || t('session.info.notAvailable') + const workingDirectory = props.session.metadata?.path || t('session.info.notAvailable') + + return ( + !open && props.onClose()}> + + + {t('session.info.title')} + + {t('session.info.description')} + + + +
+ + + + +
+
+
+ ) +} diff --git a/web/src/components/ToolCard/checklist.test.tsx b/web/src/components/ToolCard/checklist.test.tsx index 73898ba89..2d10561ea 100644 --- a/web/src/components/ToolCard/checklist.test.tsx +++ b/web/src/components/ToolCard/checklist.test.tsx @@ -133,6 +133,22 @@ describe('update_plan tool presentation', () => { }) }) +describe('Codex tool presentation', () => { + it('shows CodexWaitAgent target details in presentation', () => { + const presentation = getToolPresentation({ + toolName: 'CodexWaitAgent', + input: { targets: ['agent-1'], timeout_ms: 30000 }, + result: undefined, + childrenCount: 0, + description: null, + metadata: null + }) + + expect(presentation.title).toBe('Wait for agent') + expect(presentation.subtitle).toContain('agent-1') + }) +}) + describe('UpdatePlanView', () => { it('renders checklist rows with status styling', () => { render( diff --git a/web/src/components/ToolCard/knownTools.tsx b/web/src/components/ToolCard/knownTools.tsx index 7289ec189..0554843bb 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,122 @@ 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, ['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 + }, + BashOutput: { + icon: () => , + title: () => 'Command output', + subtitle: (opts) => { + const text = typeof opts.result === 'string' + ? opts.result + : getInputStringAny(opts.result, ['stdout', 'stderr', 'output', 'text', 'content']) + return text ? truncate(text.replace(/\r?\n/g, ' ↩ '), 120) : null + }, + minimal: true + }, + KillBash: { + icon: () => , + title: () => 'Stop command', + subtitle: (opts) => getInputTextAny(opts.input, ['target', 'pid', 'process_id']) ?? null, + minimal: true + }, + TodoRead: { + icon: () => , + title: () => 'Read todo list', + subtitle: (opts) => formatChecklistCount(extractTodoChecklist(opts.input, opts.result), 'item'), + minimal: true + }, + EnterWorktree: { + icon: () => , + title: () => 'Enter worktree', + subtitle: (opts) => getInputStringAny(opts.input, ['path', 'worktreePath', 'worktree_path']) ?? null, minimal: true }, shell_command: { diff --git a/web/src/components/ToolCard/views/_results.test.tsx b/web/src/components/ToolCard/views/_results.test.tsx index 05158961e..41d32dae0 100644 --- a/web/src/components/ToolCard/views/_results.test.tsx +++ b/web/src/components/ToolCard/views/_results.test.tsx @@ -1,5 +1,55 @@ import { describe, expect, it } from 'vitest' -import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent } from '@/components/ToolCard/views/_results' +import { afterEach } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ToolCallBlock } from '@/chat/types' +import { extractTextFromResult, getMutationResultRenderMode, getToolResultViewComponent, toolResultViewRegistry } from '@/components/ToolCard/views/_results' +import { I18nProvider } from '@/lib/i18n-context' + +function makeToolBlock(name: string, result: unknown, input: unknown = {}): ToolCallBlock { + return { + kind: 'tool-call', + id: `${name}-block`, + localId: null, + createdAt: 0, + tool: { + id: `${name}-tool`, + name, + state: 'completed', + input, + createdAt: 0, + startedAt: 0, + completedAt: 0, + description: null, + result + }, + children: [] + } +} + +function renderWithProviders(ui: React.ReactElement) { + if (typeof window !== 'undefined' && !window.matchMedia) { + window.matchMedia = () => ({ + matches: false, + media: '', + onchange: null, + addListener: () => {}, + removeListener: () => {}, + addEventListener: () => {}, + removeEventListener: () => {}, + dispatchEvent: () => false + }) + } + + return render( + + {ui} + + ) +} + +afterEach(() => { + cleanup() +}) describe('extractTextFromResult', () => { it('returns string directly', () => { @@ -96,4 +146,223 @@ describe('getToolResultViewComponent registry', () => { // Both should fall back to GenericResultView expect(mcpView).toBe(unknownView) }) + + it('routes Codex aliases to dedicated result views', () => { + expect(toolResultViewRegistry.CodexBash).toBeDefined() + expect(toolResultViewRegistry.CodexWriteStdin).toBeDefined() + expect(toolResultViewRegistry.CodexSpawnAgent).toBeDefined() + expect(toolResultViewRegistry.CodexWaitAgent).toBeDefined() + expect(toolResultViewRegistry.CodexSendInput).toBeDefined() + expect(toolResultViewRegistry.CodexCloseAgent).toBeDefined() + }) + + it('routes Codex subagent tools away from GenericResultView', () => { + const generic = getToolResultViewComponent('SomeUnknownTool') + + expect(getToolResultViewComponent('CodexWriteStdin')).not.toBe(generic) + expect(getToolResultViewComponent('CodexSpawnAgent')).not.toBe(generic) + expect(getToolResultViewComponent('CodexWaitAgent')).not.toBe(generic) + expect(getToolResultViewComponent('CodexSendInput')).not.toBe(generic) + expect(getToolResultViewComponent('CodexCloseAgent')).not.toBe(generic) + }) + + it('routes Claude parity tool names to expected result views', () => { + expect(getToolResultViewComponent('BashOutput')).toBe(getToolResultViewComponent('Bash')) + expect(getToolResultViewComponent('KillBash')).toBe(getToolResultViewComponent('SomeUnknownTool')) + expect(getToolResultViewComponent('TodoRead')).toBe(getToolResultViewComponent('TodoWrite')) + expect(getToolResultViewComponent('EnterWorktree')).toBe(getToolResultViewComponent('SomeUnknownTool')) + }) +}) + +describe('Codex alias result rendering', () => { + it('renders CodexBash object stdout and stderr output', () => { + const View = getToolResultViewComponent('CodexBash') + + renderWithProviders( + + ) + + expect(screen.getByText('command ok')).toBeInTheDocument() + expect(screen.getByText('warning output')).toBeInTheDocument() + }) + + it('renders TodoRead checklist entries through parity routing', () => { + const View = getToolResultViewComponent('TodoRead') + + renderWithProviders( + + ) + + expect(screen.getByText(/Ship web parity/)).toBeInTheDocument() + }) + + it('renders CodexWriteStdin sent input preview', () => { + const View = getToolResultViewComponent('CodexWriteStdin') + + renderWithProviders( + + ) + + expect(screen.getByText(/Sent:/)).toBeInTheDocument() + expect(screen.getByText(/ls/)).toBeInTheDocument() + }) + + it('renders CodexSpawnAgent result metadata', () => { + const View = getToolResultViewComponent('CodexSpawnAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Agent ID: agent-1')).toBeInTheDocument() + expect(screen.getByText('Nickname: Pauli')).toBeInTheDocument() + expect(screen.getByText('Prompt: Search GitHub trending')).toBeInTheDocument() + }) + + it('renders CodexCloseAgent structured status instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexCloseAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-9')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/closed/).length).toBeGreaterThan(0) + }) + + it('renders CodexSendInput structured ack instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexSendInput') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-4')).toBeInTheDocument() + expect(screen.getByText('Message: continue')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/true/).length).toBeGreaterThan(0) + }) + + it('renders CodexWaitAgent structured status map instead of no output placeholder', () => { + const View = getToolResultViewComponent('CodexWaitAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Targets: agent-1, agent-2')).toBeInTheDocument() + expect(screen.queryByText('(no output)')).not.toBeInTheDocument() + expect(screen.getAllByText(/completed/).length).toBeGreaterThan(0) + expect(screen.getAllByText(/running/).length).toBeGreaterThan(0) + }) + + it('renders CodexWaitAgent target and timeout details', () => { + const View = getToolResultViewComponent('CodexWaitAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Targets: agent-1')).toBeInTheDocument() + expect(screen.getByText('Timeout: 30000')).toBeInTheDocument() + expect(screen.getByText('agent finished')).toBeInTheDocument() + }) + + it('renders CodexSendInput target and message preview', () => { + const View = getToolResultViewComponent('CodexSendInput') + + renderWithProviders( + + ) + + expect(screen.getByText(/Target: agent-1/)).toBeInTheDocument() + expect(screen.getByText(/continue with tests/)).toBeInTheDocument() + expect(screen.getByText(/Interrupt/)).toBeInTheDocument() + }) + + it('renders CodexCloseAgent target details', () => { + const View = getToolResultViewComponent('CodexCloseAgent') + + renderWithProviders( + + ) + + expect(screen.getByText('Target: agent-1')).toBeInTheDocument() + expect(screen.getAllByText(/closed/).length).toBeGreaterThan(0) + }) }) diff --git a/web/src/components/ToolCard/views/_results.tsx b/web/src/components/ToolCard/views/_results.tsx index cb3e25364..f3e37f827 100644 --- a/web/src/components/ToolCard/views/_results.tsx +++ b/web/src/components/ToolCard/views/_results.tsx @@ -137,12 +137,56 @@ function renderText(text: string, opts: { mode: 'markdown' | 'code' | 'auto'; la return } +function renderCodexStructuredResult( + result: unknown, + state: ToolViewProps['block']['tool']['state'] +): React.ReactNode { + const text = extractTextFromResult(result) + if (text) { + return renderText(text, { mode: 'code', language: 'text' }) + } + + if (result !== null && result !== undefined && typeof result === 'object') { + return + } + + return
{placeholderForState(state)}
+} + function placeholderForState(state: ToolViewProps['block']['tool']['state']): string { if (state === 'pending') return 'Waiting for permission…' if (state === 'running') return 'Running…' return '(no output)' } +function getStringList(value: unknown): string[] { + if (!Array.isArray(value)) return [] + return value.filter((item): item is string => typeof item === 'string' && item.length > 0) +} + +function getInputString(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 renderToolMetaLines(lines: string[]) { + const visible = lines.filter((line) => line.length > 0) + if (visible.length === 0) return null + + return ( +
+ {visible.map((line) => ( +
{line}
+ ))} +
+ ) +} + function RawJsonDevOnly(props: { value: unknown }) { if (!import.meta.env.DEV) return null if (props.value === null || props.value === undefined) return null @@ -497,6 +541,101 @@ const CodexDiffResultView: ToolViewComponent = (props: ToolViewProps) => { ) } +const CodexWriteStdinResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const chars = input && typeof input.chars === 'string' ? input.chars : null + const target = getInputString(props.block.tool.input, ['target', 'session_id', 'sessionId']) + + return ( +
+ {renderToolMetaLines([ + chars && chars.length > 0 + ? `Sent: ${chars.replace(/\r?\n/g, ' ↩ ')}` + : target ? `Poll target: ${target}` : 'Poll output' + ])} + {renderCodexStructuredResult(result, props.block.tool.state)} + +
+ ) +} + +const CodexSpawnAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = isObject(props.block.tool.result) ? props.block.tool.result : null + const agentId = result && typeof result.agent_id === 'string' ? result.agent_id : null + const nickname = result && typeof result.nickname === 'string' ? result.nickname : getInputString(input, ['nickname', 'name', 'agent_name']) + const message = getInputString(input, ['message', 'messagePreview', 'prompt', 'description']) + const model = getInputString(input, ['model']) + + return ( +
+ {renderToolMetaLines([ + agentId ? `Agent ID: ${agentId}` : '', + nickname ? `Nickname: ${nickname}` : '', + model ? `Model: ${model}` : '', + message ? `Prompt: ${message}` : '' + ])} + {renderCodexStructuredResult(props.block.tool.result, props.block.tool.state)} + +
+ ) +} + +const CodexWaitAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const targets = getStringList(input?.targets) + const timeout = getInputString(input, ['timeout_ms', 'timeout']) + + return ( +
+ {renderToolMetaLines([ + targets.length > 0 ? `Targets: ${targets.join(', ')}` : '', + timeout ? `Timeout: ${timeout}` : '' + ])} + {renderCodexStructuredResult(result, props.block.tool.state)} + +
+ ) +} + +const CodexSendInputResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const target = getInputString(input, ['target']) + const message = getInputString(input, ['message', 'messagePreview']) + const interrupt = input?.interrupt === true + + return ( +
+ {renderToolMetaLines([ + target ? `Target: ${target}` : '', + interrupt ? 'Interrupt' : '', + message ? `Message: ${message}` : '' + ])} + {renderCodexStructuredResult(result, props.block.tool.state)} + +
+ ) +} + +const CodexCloseAgentResultView: ToolViewComponent = (props: ToolViewProps) => { + const input = isObject(props.block.tool.input) ? props.block.tool.input : null + const result = props.block.tool.result + const target = getInputString(input, ['target', 'agent_id', 'agentId']) + + return ( +
+ {renderToolMetaLines([ + target ? `Target: ${target}` : '' + ])} + {renderCodexStructuredResult(result, props.block.tool.state)} + +
+ ) +} + const TodoWriteResultView: ToolViewComponent = (props: ToolViewProps) => { const todos = extractTodoChecklist(props.block.tool.input, props.block.tool.result) if (todos.length === 0) { @@ -551,6 +690,7 @@ const GenericResultView: ToolViewComponent = (props: ToolViewProps) => { export const toolResultViewRegistry: Record = { Task: MarkdownResultView, Bash: BashResultView, + BashOutput: BashResultView, Glob: LineListResultView, Grep: LineListResultView, LS: LineListResultView, @@ -563,9 +703,18 @@ export const toolResultViewRegistry: Record = { NotebookRead: ReadResultView, NotebookEdit: MutationResultView, TodoWrite: TodoWriteResultView, + TodoRead: TodoWriteResultView, CodexReasoning: CodexReasoningResultView, + CodexBash: BashResultView, + CodexWriteStdin: CodexWriteStdinResultView, + CodexSpawnAgent: CodexSpawnAgentResultView, + CodexWaitAgent: CodexWaitAgentResultView, + CodexSendInput: CodexSendInputResultView, + CodexCloseAgent: CodexCloseAgentResultView, CodexPatch: CodexPatchResultView, CodexDiff: CodexDiffResultView, + KillBash: GenericResultView, + EnterWorktree: GenericResultView, AskUserQuestion: AskUserQuestionResultView, ExitPlanMode: MarkdownResultView, ask_user_question: AskUserQuestionResultView, diff --git a/web/src/components/ui/dialog.tsx b/web/src/components/ui/dialog.tsx index 18a8f4fbe..30d69a2d2 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/queries/useImportableSessions.ts b/web/src/hooks/queries/useImportableSessions.ts new file mode 100644 index 000000000..d68226005 --- /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/lib/attachmentAdapter.test.ts b/web/src/lib/attachmentAdapter.test.ts new file mode 100644 index 000000000..eb39c39cc --- /dev/null +++ b/web/src/lib/attachmentAdapter.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it, vi } from 'vitest' +import { createAttachmentAdapter } from './attachmentAdapter' + +describe('createAttachmentAdapter', () => { + it('uploads attachments to the resolved active session', async () => { + const uploadFile = vi.fn().mockResolvedValue({ + success: true, + path: '/tmp/uploaded.png' + }) + const resolveSessionId = vi.fn().mockResolvedValue('session-active') + const onSessionResolved = vi.fn() + const api = { + uploadFile, + deleteUploadFile: vi.fn() + } + const adapter = createAttachmentAdapter(api as never, 'session-inactive', { + resolveSessionId, + onSessionResolved + }) + + const file = new File(['image-bytes'], 'photo.png', { type: 'image/png' }) + const states: unknown[] = [] + const addResult = adapter.add({ file }) as AsyncGenerator + for await (const state of addResult) { + states.push(state) + } + + expect(resolveSessionId).toHaveBeenCalledWith('session-inactive') + expect(onSessionResolved).toHaveBeenCalledWith('session-active') + expect(uploadFile).toHaveBeenCalledWith( + 'session-active', + 'photo.png', + expect.any(String), + 'image/png' + ) + expect(states).toHaveLength(3) + }) + + it('deletes uploaded attachments from the resolved session', async () => { + const uploadFile = vi.fn().mockResolvedValue({ + success: true, + path: '/tmp/uploaded.png' + }) + const deleteUploadFile = vi.fn().mockResolvedValue({ success: true }) + const api = { + uploadFile, + deleteUploadFile + } + const adapter = createAttachmentAdapter(api as never, 'session-inactive', { + resolveSessionId: vi.fn().mockResolvedValue('session-active'), + onSessionResolved: vi.fn() + }) + + const file = new File(['image-bytes'], 'photo.png', { type: 'image/png' }) + let uploaded: unknown = null + const addResult = adapter.add({ file }) as AsyncGenerator + for await (const state of addResult) { + uploaded = state + } + + await adapter.remove(uploaded as never) + + expect(deleteUploadFile).toHaveBeenCalledWith('session-active', '/tmp/uploaded.png') + }) +}) diff --git a/web/src/lib/attachmentAdapter.ts b/web/src/lib/attachmentAdapter.ts index 5e3fd325b..261ffb58b 100644 --- a/web/src/lib/attachmentAdapter.ts +++ b/web/src/lib/attachmentAdapter.ts @@ -2,22 +2,103 @@ import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachme import type { ApiClient } from '@/api/client' import type { AttachmentMetadata } from '@/types/api' import { isImageMimeType } from '@/lib/fileAttachments' +import { makeClientSideId } from '@/lib/messages' const MAX_UPLOAD_BYTES = 50 * 1024 * 1024 const MAX_PREVIEW_BYTES = 5 * 1024 * 1024 +export type AttachmentUploadOptions = { + resolveSessionId?: (sessionId: string) => Promise + onSessionResolved?: (sessionId: string) => void +} + type PendingUploadAttachment = PendingAttachment & { path?: string previewUrl?: string + sessionId?: string +} + +export async function resolveAttachmentSessionId( + sessionId: string, + options?: AttachmentUploadOptions +): Promise { + if (!options?.resolveSessionId) { + return sessionId + } + const resolvedSessionId = await options.resolveSessionId(sessionId) + if (resolvedSessionId !== sessionId) { + options.onSessionResolved?.(resolvedSessionId) + } + return resolvedSessionId } -export function createAttachmentAdapter(api: ApiClient, sessionId: string): AttachmentAdapter { +export async function uploadAttachmentFile(args: { + api: ApiClient + sessionId: string + file: File + options?: AttachmentUploadOptions +}): Promise<{ metadata: AttachmentMetadata; sessionId: string }> { + const contentType = args.file.type || 'application/octet-stream' + if (args.file.size > MAX_UPLOAD_BYTES) { + throw new Error('File too large (max 50MB)') + } + + const targetSessionId = await resolveAttachmentSessionId(args.sessionId, args.options) + const content = await fileToBase64(args.file) + const result = await args.api.uploadFile(targetSessionId, args.file.name, content, contentType) + if (!result.success || !result.path) { + throw new Error(result.error || 'Failed to upload file') + } + + let previewUrl: string | undefined + if (isImageMimeType(contentType) && args.file.size <= MAX_PREVIEW_BYTES) { + previewUrl = await fileToDataUrl(args.file) + } + + return { + sessionId: targetSessionId, + metadata: { + id: makeClientSideId('attachment'), + filename: args.file.name, + mimeType: contentType, + size: args.file.size, + path: result.path, + previewUrl + } + } +} + +export async function deleteUploadedAttachment(args: { + api: ApiClient + sessionId: string + path?: string +}): Promise { + if (!args.path) { + return + } + await args.api.deleteUploadFile(args.sessionId, args.path) +} + +export function createAttachmentAdapter(api: ApiClient, sessionId: string, options?: AttachmentUploadOptions): AttachmentAdapter { const cancelledAttachmentIds = new Set() + let currentSessionId = sessionId + + const resolveTargetSessionId = async (): Promise => { + const resolvedSessionId = await resolveAttachmentSessionId(currentSessionId, options) + if (resolvedSessionId !== currentSessionId) { + currentSessionId = resolvedSessionId + } + return currentSessionId + } - const deleteUpload = async (path?: string) => { + const deleteUpload = async (path?: string, targetSessionId?: string) => { if (!path) return try { - await api.deleteUploadFile(sessionId, path) + await deleteUploadedAttachment({ + api, + sessionId: targetSessionId ?? currentSessionId, + path + }) } catch { // Best effort cleanup } @@ -27,7 +108,7 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta accept: '*/*', async *add({ file }): AsyncGenerator { - const id = crypto.randomUUID() + const id = makeClientSideId('attachment') const contentType = file.type || 'application/octet-stream' yield { @@ -44,6 +125,11 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta return } + const targetSessionId = await resolveTargetSessionId() + if (cancelledAttachmentIds.has(id)) { + return + } + if (file.size > MAX_UPLOAD_BYTES) { yield { id, @@ -70,10 +156,10 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta status: { type: 'running', reason: 'uploading', progress: 50 } } - const result = await api.uploadFile(sessionId, file.name, content, contentType) + const result = await api.uploadFile(targetSessionId, file.name, content, contentType) if (cancelledAttachmentIds.has(id)) { if (result.success && result.path) { - await deleteUpload(result.path) + await deleteUpload(result.path, targetSessionId) } return } @@ -104,7 +190,8 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta file, status: { type: 'requires-action', reason: 'composer-send' }, path: result.path, - previewUrl + previewUrl, + sessionId: targetSessionId } as PendingUploadAttachment } catch { yield { @@ -120,8 +207,8 @@ export function createAttachmentAdapter(api: ApiClient, sessionId: string): Atta async remove(attachment: Attachment): Promise { cancelledAttachmentIds.add(attachment.id) - const path = (attachment as PendingUploadAttachment).path - await deleteUpload(path) + const pending = attachment as PendingUploadAttachment + await deleteUpload(pending.path, pending.sessionId) }, async send(attachment: PendingAttachment): Promise { diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index f26126109..5042ed2ea 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -59,11 +59,21 @@ export default { 'session.more': 'More actions', // Session actions + 'session.action.info': 'Session Info', 'session.action.rename': 'Rename', 'session.action.archive': 'Archive', 'session.action.delete': 'Delete', 'session.action.copy': 'Copy', + // Session info + 'session.info.title': 'Session Info', + 'session.info.description': 'Read-only identifiers and source details for this session.', + 'session.info.agent': 'Agent', + 'session.info.workingDirectory': 'Working directory', + 'session.info.hapiSessionId': 'HAPI session id', + 'session.info.sourceSessionId': 'Source session id', + 'session.info.notAvailable': 'Not available', + // Dialogs 'dialog.rename.title': 'Rename Session', 'dialog.rename.placeholder': 'Session name', @@ -111,6 +121,27 @@ 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', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index ea220f5a7..3982a825b 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -59,11 +59,21 @@ export default { 'session.more': '更多操作', // Session actions + 'session.action.info': '会话详情', 'session.action.rename': '重命名', 'session.action.archive': '归档', 'session.action.delete': '删除', 'session.action.copy': '复制', + // Session info + 'session.info.title': '会话详情', + 'session.info.description': '查看当前会话的只读标识和来源信息。', + 'session.info.agent': '代理', + 'session.info.workingDirectory': '工作目录', + 'session.info.hapiSessionId': 'HAPI 会话 ID', + 'session.info.sourceSessionId': '源会话 ID', + 'session.info.notAvailable': '不可用', + // Dialogs 'dialog.rename.title': '重命名会话', 'dialog.rename.placeholder': '会话名称', @@ -113,6 +123,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': '创建会话', diff --git a/web/src/lib/query-keys.ts b/web/src/lib/query-keys.ts index a00b5512b..493033645 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/lib/viteAllowedHosts.test.ts b/web/src/lib/viteAllowedHosts.test.ts new file mode 100644 index 000000000..b4e6462b0 --- /dev/null +++ b/web/src/lib/viteAllowedHosts.test.ts @@ -0,0 +1,8 @@ +import { describe, expect, it } from 'vitest' +import { getAllowedHosts } from './viteAllowedHosts' + +describe('getAllowedHosts', () => { + it('includes the public hapidev host', () => { + expect(getAllowedHosts()).toContain('hapidev.duxiaoxiong.top') + }) +}) diff --git a/web/src/lib/viteAllowedHosts.ts b/web/src/lib/viteAllowedHosts.ts new file mode 100644 index 000000000..65de54c3a --- /dev/null +++ b/web/src/lib/viteAllowedHosts.ts @@ -0,0 +1,16 @@ +const DEFAULT_ALLOWED_HOSTS = [ + 'hapidev.weishu.me', + 'hapidev.duxiaoxiong.top' +] as const + +export function getAllowedHosts(extraHostsValue = ''): string[] { + const extraHosts = extraHostsValue + .split(',') + .map((host: string) => host.trim()) + .filter(Boolean) + + return Array.from(new Set([ + ...DEFAULT_ALLOWED_HOSTS, + ...extraHosts + ])) +} diff --git a/web/src/main.tsx b/web/src/main.tsx index 31c1def66..4570f964f 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -76,12 +76,14 @@ async function bootstrap() { : undefined const router = createAppRouter(history) + const showQueryDevtools = import.meta.env.DEV && import.meta.env.VITE_SHOW_QUERY_DEVTOOLS === 'true' + ReactDOM.createRoot(document.getElementById('root')!).render( - {import.meta.env.DEV ? : null} + {showQueryDevtools ? : null} diff --git a/web/src/router.tsx b/web/src/router.tsx index 7527abcdd..f53d997c8 100644 --- a/web/src/router.tsx +++ b/web/src/router.tsx @@ -220,55 +220,58 @@ function SessionPage() { flushPending, setAtBottom, } = useMessages(api, sessionId) + const resolveActiveSessionId = useCallback(async (currentSessionId: string) => { + if (!api || !session || session.active) { + return currentSessionId + } + try { + return await api.resumeSession(currentSessionId) + } catch (error) { + const message = error instanceof Error ? error.message : 'Resume failed' + addToast({ + title: 'Resume failed', + body: message, + sessionId: currentSessionId, + url: '' + }) + throw error + } + }, [addToast, api, session]) + + const handleSessionResolved = useCallback((resolvedSessionId: string) => { + void (async () => { + if (api) { + if (session && resolvedSessionId !== session.id) { + seedMessageWindowFromSession(session.id, resolvedSessionId) + queryClient.setQueryData(queryKeys.session(resolvedSessionId), { + session: { ...session, id: resolvedSessionId, active: true } + }) + } + try { + await Promise.all([ + queryClient.prefetchQuery({ + queryKey: queryKeys.session(resolvedSessionId), + queryFn: () => api.getSession(resolvedSessionId), + }), + fetchLatestMessages(api, resolvedSessionId), + ]) + } catch { + } + } + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: resolvedSessionId }, + replace: true + }) + })() + }, [api, navigate, queryClient, session]) const { sendMessage, retryMessage, isSending, } = useSendMessage(api, sessionId, { - resolveSessionId: async (currentSessionId) => { - if (!api || !session || session.active) { - return currentSessionId - } - try { - return await api.resumeSession(currentSessionId) - } catch (error) { - const message = error instanceof Error ? error.message : 'Resume failed' - addToast({ - title: 'Resume failed', - body: message, - sessionId: currentSessionId, - url: '' - }) - throw error - } - }, - onSessionResolved: (resolvedSessionId) => { - void (async () => { - if (api) { - if (session && resolvedSessionId !== session.id) { - seedMessageWindowFromSession(session.id, resolvedSessionId) - queryClient.setQueryData(queryKeys.session(resolvedSessionId), { - session: { ...session, id: resolvedSessionId, active: true } - }) - } - try { - await Promise.all([ - queryClient.prefetchQuery({ - queryKey: queryKeys.session(resolvedSessionId), - queryFn: () => api.getSession(resolvedSessionId), - }), - fetchLatestMessages(api, resolvedSessionId), - ]) - } catch { - } - } - navigate({ - to: '/sessions/$sessionId', - params: { sessionId: resolvedSessionId }, - replace: true - }) - })() - }, + resolveSessionId: resolveActiveSessionId, + onSessionResolved: handleSessionResolved, onBlocked: (reason) => { if (reason === 'no-api') { addToast({ @@ -328,6 +331,8 @@ function SessionPage() { onRefresh={refreshSelectedSession} onLoadMore={loadMoreMessages} onSend={sendMessage} + resolveSessionId={resolveActiveSessionId} + onSessionResolved={handleSessionResolved} onFlushPending={flushPending} onAtBottomChange={setAtBottom} onRetryMessage={retryMessage} diff --git a/web/src/types/api.ts b/web/src/types/api.ts index 0a2b01b14..5f289d081 100644 --- a/web/src/types/api.ts +++ b/web/src/types/api.ts @@ -95,6 +95,29 @@ 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 + 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 } diff --git a/web/vite.config.ts b/web/vite.config.ts index 631ac9813..25485eb15 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' import { VitePWA } from 'vite-plugin-pwa' import { resolve } from 'node:path' import { createRequire } from 'node:module' +import { getAllowedHosts } from './src/lib/viteAllowedHosts' const require = createRequire(import.meta.url) const base = process.env.VITE_BASE_URL || '/' @@ -38,7 +39,7 @@ export default defineConfig({ }, server: { host: true, - allowedHosts: ['hapidev.weishu.me'], + allowedHosts: getAllowedHosts(process.env.VITE_ALLOWED_HOSTS ?? ''), proxy: { '/api': { target: hubTarget,