diff --git a/README.md b/README.md index 92032d505b..fd446f1e9d 100644 --- a/README.md +++ b/README.md @@ -7,6 +7,7 @@ Run official Claude Code / Codex / Gemini / OpenCode sessions locally and contro ## Features - **Seamless Handoff** - Work locally, switch to remote when needed, switch back anytime. No context loss, no session restart. +- **Codex Forking** - Fork an existing Codex conversation into a brand-new session from CLI or web. - **Native First** - HAPI wraps your AI agent instead of replacing it. Same terminal, same experience, same muscle memory. - **AFK Without Stopping** - Step away from your desk? Approve AI requests from your phone with one tap. - **Your AI, Your Choice** - Claude Code, Codex, Cursor Agent, Gemini, OpenCode—different models, one unified workflow. diff --git a/cli/README.md b/cli/README.md index a2c0623cc0..cb6b86e529 100644 --- a/cli/README.md +++ b/cli/README.md @@ -27,6 +27,7 @@ Run Claude Code, Codex, Cursor Agent, Gemini, or OpenCode sessions from your ter - `hapi` - Start a Claude Code session (passes through Claude CLI flags). See `src/index.ts`. - `hapi codex` - Start Codex mode. See `src/codex/runCodex.ts`. - `hapi codex resume ` - Resume existing Codex session. +- `hapi codex fork ` - Fork existing Codex session into a new session. - `hapi cursor` - Start Cursor Agent mode. See `src/cursor/runCursor.ts`. Supports `hapi cursor resume `, `hapi cursor --continue`, `--mode plan|ask`, `--yolo`, `--model`. Local and remote modes supported; remote uses `agent -p` with stream-json. diff --git a/cli/src/agent/runners/runAgentSession.ts b/cli/src/agent/runners/runAgentSession.ts index 654bcd8556..2c51d39516 100644 --- a/cli/src/agent/runners/runAgentSession.ts +++ b/cli/src/agent/runners/runAgentSession.ts @@ -51,7 +51,8 @@ export async function runAgentSession(opts: { const messageQueue = new MessageQueue2>(() => hashObject({})); - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { + const localId = meta.localId const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); messageQueue.push(formattedText, {}, localId); }); diff --git a/cli/src/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 99a7d3344c..7166344f57 100644 --- a/cli/src/api/apiMachine.ts +++ b/cli/src/api/apiMachine.ts @@ -276,7 +276,7 @@ export class ApiMachineClient { setRPCHandlers({ spawnSession, stopSession, requestShutdown }: MachineRpcHandlers): void { this.rpcHandlerManager.registerHandler('spawn-happy-session', async (params: any) => { - const { directory, sessionId, resumeSessionId, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} + const { directory, sessionId, resumeSessionId, forkSessionId, forkHistory, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -291,6 +291,8 @@ export class ApiMachineClient { directory, sessionId, resumeSessionId, + forkSessionId, + forkHistory, machineId, approvedNewDirectoryCreation, agent, diff --git a/cli/src/api/apiSession.ts b/cli/src/api/apiSession.ts index 2ac631c426..c074de097c 100644 --- a/cli/src/api/apiSession.ts +++ b/cli/src/api/apiSession.ts @@ -48,6 +48,11 @@ const SYSTEM_INJECTION_PREFIXES = [ '', ] +export type UserMessageMeta = { + localId?: string + seq?: number | null +} + /** * Returns true if a JSONL message should be classified as a user-role message * (i.e., text typed by a real human) rather than an agent-role message. @@ -79,8 +84,8 @@ export class ApiSessionClient extends EventEmitter { private agentState: AgentState | null private agentStateVersion: number private readonly socket: Socket - private pendingMessages: { message: UserMessage; localId?: string }[] = [] - private pendingMessageCallback: ((message: UserMessage, localId?: string) => void) | null = null + private pendingMessages: { message: UserMessage; meta: UserMessageMeta }[] = [] + private pendingMessageCallback: ((message: UserMessage, meta: UserMessageMeta) => void) | null = null private lastSeenMessageSeq: number | null = null private backfillInFlight: Promise | null = null private needsBackfill = false @@ -245,19 +250,19 @@ export class ApiSessionClient extends EventEmitter { this.socket.connect() } - onUserMessage(callback: (data: UserMessage, localId?: string) => void): void { + onUserMessage(callback: (data: UserMessage, meta: UserMessageMeta) => void): void { this.pendingMessageCallback = callback while (this.pendingMessages.length > 0) { const pending = this.pendingMessages.shift()! - callback(pending.message, pending.localId) + callback(pending.message, pending.meta) } } - private enqueueUserMessage(message: UserMessage, localId?: string): void { + private enqueueUserMessage(message: UserMessage, meta: UserMessageMeta): void { if (this.pendingMessageCallback) { - this.pendingMessageCallback(message, localId) + this.pendingMessageCallback(message, meta) } else { - this.pendingMessages.push({ message, localId }) + this.pendingMessages.push({ message, meta }) } } @@ -272,7 +277,10 @@ export class ApiSessionClient extends EventEmitter { const userResult = UserMessageSchema.safeParse(message.content) if (userResult.success) { - this.enqueueUserMessage(userResult.data, message.localId ?? undefined) + this.enqueueUserMessage(userResult.data, { + localId: message.localId ?? undefined, + seq + }) return } @@ -500,6 +508,20 @@ export class ApiSessionClient extends EventEmitter { this.socket.emit('messages-consumed', { sid: this.sessionId, localIds }) } + sendCodexHistoryItem(item: { + codexThreadId: string + turnId?: string | null + itemId: string + itemKind: 'user' | 'assistant' | 'tool' | 'event' | 'unknown' + messageSeq?: number | null + rawItem: unknown + }): void { + this.socket.emit('codex-history-item', { + sid: this.sessionId, + ...item + }) + } + sendSessionDeath(reason?: SessionEndReason): void { void cleanupUploadDir(this.sessionId) this.socket.emit('session-end', { sid: this.sessionId, time: Date.now(), reason }) diff --git a/cli/src/claude/runClaude.ts b/cli/src/claude/runClaude.ts index c7aae047bc..4771961703 100644 --- a/cli/src/claude/runClaude.ts +++ b/cli/src/claude/runClaude.ts @@ -170,7 +170,8 @@ export async function runClaude(options: StartOptions = {}): Promise { sessionInstance.setEffort(currentEffort); logger.debug(`[loop] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${currentModel ?? 'auto'}, effort=${currentEffort ?? 'auto'}`); }; - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { + const localId = meta.localId const sessionPermissionMode = currentSessionRef.current?.getPermissionMode(); if (sessionPermissionMode && isPermissionModeAllowedForFlavor(sessionPermissionMode, 'claude')) { currentPermissionMode = sessionPermissionMode as PermissionMode; diff --git a/cli/src/codex/appServerTypes.ts b/cli/src/codex/appServerTypes.ts index 2f13080a87..d3ae06ae6d 100644 --- a/cli/src/codex/appServerTypes.ts +++ b/cli/src/codex/appServerTypes.ts @@ -91,6 +91,29 @@ export interface ThreadResumeResponse { [key: string]: unknown; } +export interface ThreadForkParams { + threadId: string; + path?: string; + model?: string; + modelProvider?: string; + cwd?: string; + approvalPolicy?: ApprovalPolicy; + sandbox?: SandboxMode; + config?: Record; + baseInstructions?: string; + developerInstructions?: string; + ephemeral?: boolean; + persistExtendedHistory: boolean; +} + +export interface ThreadForkResponse { + thread: { + id: string; + }; + model: string; + [key: string]: unknown; +} + export type UserInput = | { type: 'text'; diff --git a/cli/src/codex/codexAppServerClient.ts b/cli/src/codex/codexAppServerClient.ts index e3a1ac95fc..831e001ccf 100644 --- a/cli/src/codex/codexAppServerClient.ts +++ b/cli/src/codex/codexAppServerClient.ts @@ -10,6 +10,8 @@ import type { ThreadStartResponse, ThreadResumeParams, ThreadResumeResponse, + ThreadForkParams, + ThreadForkResponse, TurnStartParams, TurnStartResponse, TurnInterruptParams, @@ -160,6 +162,14 @@ export class CodexAppServerClient { return response as ThreadResumeResponse; } + async forkThread(params: ThreadForkParams, options?: { signal?: AbortSignal }): Promise { + const response = await this.sendRequest('thread/fork', params, { + signal: options?.signal, + timeoutMs: CodexAppServerClient.DEFAULT_TIMEOUT_MS + }); + return response as ThreadForkResponse; + } + async startTurn(params: TurnStartParams, options?: { signal?: AbortSignal }): Promise { const response = await this.sendRequest('turn/start', params, { signal: options?.signal, diff --git a/cli/src/codex/codexLocal.test.ts b/cli/src/codex/codexLocal.test.ts index 2d251c27de..b61160a522 100644 --- a/cli/src/codex/codexLocal.test.ts +++ b/cli/src/codex/codexLocal.test.ts @@ -10,42 +10,54 @@ vi.mock('@/utils/spawnWithTerminalGuard', () => ({ vi.mock('@/ui/logger', () => ({ logger: { - debug: vi.fn() + debug: vi.fn(), + warn: vi.fn() } })); -import { codexLocal, filterResumeSubcommand } from './codexLocal'; +import { codexLocal, filterManagedSessionSubcommand } from './codexLocal'; -describe('filterResumeSubcommand', () => { +describe('filterManagedSessionSubcommand', () => { it('returns empty array unchanged', () => { - expect(filterResumeSubcommand([])).toEqual([]); + expect(filterManagedSessionSubcommand([])).toEqual([]); }); it('passes through args when first arg is not resume', () => { - expect(filterResumeSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']); - expect(filterResumeSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']); + expect(filterManagedSessionSubcommand(['--model', 'gpt-4'])).toEqual(['--model', 'gpt-4']); + expect(filterManagedSessionSubcommand(['--sandbox', 'read-only'])).toEqual(['--sandbox', 'read-only']); }); it('filters resume subcommand with session ID', () => { - expect(filterResumeSubcommand(['resume', 'abc-123'])).toEqual([]); - expect(filterResumeSubcommand(['resume', 'abc-123', '--model', 'gpt-4'])) + expect(filterManagedSessionSubcommand(['resume', 'abc-123'])).toEqual([]); + expect(filterManagedSessionSubcommand(['resume', 'abc-123', '--model', 'gpt-4'])) .toEqual(['--model', 'gpt-4']); }); it('filters resume subcommand without session ID', () => { - expect(filterResumeSubcommand(['resume'])).toEqual([]); - expect(filterResumeSubcommand(['resume', '--model', 'gpt-4'])) + expect(filterManagedSessionSubcommand(['resume'])).toEqual([]); + expect(filterManagedSessionSubcommand(['resume', '--model', 'gpt-4'])) + .toEqual(['--model', 'gpt-4']); + }); + + it('filters fork subcommand with session ID', () => { + expect(filterManagedSessionSubcommand(['fork', 'abc-123'])).toEqual([]); + expect(filterManagedSessionSubcommand(['fork', 'abc-123', '--model', 'gpt-4'])) .toEqual(['--model', 'gpt-4']); }); it('does not filter resume when it appears as flag value', () => { - expect(filterResumeSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']); + expect(filterManagedSessionSubcommand(['--name', 'resume'])).toEqual(['--name', 'resume']); }); it('does not filter resume in middle of args', () => { - expect(filterResumeSubcommand(['--model', 'gpt-4', 'resume', '123'])) + expect(filterManagedSessionSubcommand(['--model', 'gpt-4', 'resume', '123'])) .toEqual(['--model', 'gpt-4', 'resume', '123']); }); + + it('does not filter fork in middle of args', () => { + expect(filterManagedSessionSubcommand(['--model', 'gpt-4', 'fork', '123'])) + .toEqual(['--model', 'gpt-4', 'fork', '123']); + }); }); describe('codexLocal', () => { @@ -58,7 +70,7 @@ describe('codexLocal', () => { await codexLocal({ abort: controller.signal, - sessionId: null, + resumeSessionId: null, path: 'C:\\workspace\\project', onSessionFound: vi.fn(), mcpServers: { diff --git a/cli/src/codex/codexLocal.ts b/cli/src/codex/codexLocal.ts index 60e46f0c4f..b2c7e07bed 100644 --- a/cli/src/codex/codexLocal.ts +++ b/cli/src/codex/codexLocal.ts @@ -9,27 +9,28 @@ import { codexSystemPrompt } from './utils/systemPrompt'; import type { ReasoningEffort } from './appServerTypes'; /** - * Filter out 'resume' subcommand which is managed internally by hapi. - * Codex CLI format is `codex resume `, so subcommand is always first. + * Filter out HAPI-managed session subcommands which are handled internally. + * Codex CLI format is `codex `, so the subcommand is always first. */ -export function filterResumeSubcommand(args: string[]): string[] { - if (args.length === 0 || args[0] !== 'resume') { +export function filterManagedSessionSubcommand(args: string[]): string[] { + if (args.length === 0 || (args[0] !== 'resume' && args[0] !== 'fork')) { return args; } - // First arg is 'resume', filter it and optional session ID + // First arg is 'resume' or 'fork'; filter it and optional session ID if (args.length > 1 && !args[1].startsWith('-')) { - logger.debug(`[CodexLocal] Filtered 'resume ${args[1]}' - session managed by hapi`); + logger.debug(`[CodexLocal] Filtered '${args[0]} ${args[1]}' - session managed by hapi`); return args.slice(2); } - logger.debug(`[CodexLocal] Filtered 'resume' - session managed by hapi`); + logger.debug(`[CodexLocal] Filtered '${args[0]}' - session managed by hapi`); return args.slice(1); } export async function codexLocal(opts: { abort: AbortSignal; - sessionId: string | null; + resumeSessionId: string | null; + forkSessionId?: string; path: string; model?: string; modelReasoningEffort?: ReasoningEffort; @@ -44,9 +45,11 @@ export async function codexLocal(opts: { }): Promise { const args: string[] = []; - if (opts.sessionId) { - args.push('resume', opts.sessionId); - opts.onSessionFound(opts.sessionId); + if (opts.forkSessionId) { + args.push('fork', opts.forkSessionId); + } else if (opts.resumeSessionId) { + args.push('resume', opts.resumeSessionId); + opts.onSessionFound(opts.resumeSessionId); } if (opts.model) { @@ -74,7 +77,7 @@ export async function codexLocal(opts: { args.push(...buildDeveloperInstructionsArg(codexSystemPrompt)); if (opts.codexArgs) { - const safeArgs = filterResumeSubcommand(opts.codexArgs); + const safeArgs = filterManagedSessionSubcommand(opts.codexArgs); args.push(...safeArgs); } diff --git a/cli/src/codex/codexLocalLauncher.ts b/cli/src/codex/codexLocalLauncher.ts index 5d79340d27..b90ca65482 100644 --- a/cli/src/codex/codexLocalLauncher.ts +++ b/cli/src/codex/codexLocalLauncher.ts @@ -12,6 +12,7 @@ import { BaseLocalLauncher } from '@/modules/common/launcher/BaseLocalLauncher'; export async function codexLocalLauncher(session: CodexSession): Promise<'switch' | 'exit'> { const resumeSessionId = session.sessionId; + const forkSessionId = session.forkSessionId; let primarySessionId = resumeSessionId; let primaryTranscriptPath: string | null = null; let scanner: CodexSessionScanner | null = null; @@ -168,7 +169,8 @@ export async function codexLocalLauncher(session: CodexSession): Promise<'switch launch: async (abortSignal) => { await codexLocal({ path: session.path, - sessionId: resumeSessionId, + resumeSessionId, + forkSessionId, modelReasoningEffort: (session.getModelReasoningEffort() ?? undefined) as ReasoningEffort | undefined, onSessionFound: handleSessionFound, abort: abortSignal, diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index 7e1f61940a..a2f93118db 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -8,11 +8,33 @@ const harness = vi.hoisted(() => ({ initializeCalls: [] as unknown[], startThreadIds: [] as string[], resumeThreadIds: [] as string[], + resumeThreadCalls: [] as unknown[], startTurnThreadIds: [] as string[], interruptedTurns: [] as Array<{ threadId: string; turnId: string }>, compactThreadIds: [] as string[], suppressTurnCompletion: false, - remainingThreadSystemErrors: 0 + remainingThreadSystemErrors: 0, + forkCalls: [] as unknown[], + resumeShouldThrow: null as Error | null +})); + +vi.mock('react', () => ({ + default: { + createElement: () => ({}) + } +})); + +vi.mock('ink', () => ({ + render: () => ({ + unmount: () => {} + }) +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + warn: () => {} + } })); vi.mock('./codexAppServerClient', () => { @@ -43,9 +65,18 @@ vi.mock('./codexAppServerClient', () => { async resumeThread(params?: { threadId?: string }): Promise<{ thread: { id: string }; model: string }> { const id = params?.threadId ?? 'thread-resumed'; harness.resumeThreadIds.push(id); + harness.resumeThreadCalls.push(params); + if (harness.resumeShouldThrow) { + throw harness.resumeShouldThrow; + } return { thread: { id }, model: 'gpt-5.4' }; } + async forkThread(params: unknown): Promise<{ thread: { id: string }; model: string }> { + harness.forkCalls.push(params); + return { thread: { id: 'thread-forked' }, model: 'gpt-5.4' }; + } + async startTurn(params?: { threadId?: string }): Promise<{ turn: { id?: string } }> { const threadId = params?.threadId ?? 'thread-unknown'; harness.startTurnThreadIds.push(threadId); @@ -144,19 +175,22 @@ function createMode(): EnhancedMode { }; } -function createSessionStub(messages = ['hello from launcher test']) { +function createSessionStub(opts?: { messages?: string[]; messageSeqs?: number[]; forkSessionId?: string | null; forkHistory?: unknown[] }) { + const messages = opts?.messages ?? ['hello from launcher test']; const queue = new MessageQueue2((mode) => JSON.stringify(mode)); messages.forEach((message, index) => { + const messageSeq = opts?.messageSeqs?.[index] ?? null; if (index === 0 && messages.length > 1) { - queue.pushIsolateAndClear(message, createMode()); + queue.pushIsolateAndClear(message, createMode(), undefined, messageSeq); } else { - queue.push(message, createMode()); + queue.push(message, createMode(), undefined, messageSeq); } }); queue.close(); const sessionEvents: Array<{ type: string; [key: string]: unknown }> = []; const codexMessages: unknown[] = []; + const historyItems: unknown[] = []; const thinkingChanges: boolean[] = []; const foundSessionIds: string[] = []; const resetThreadCalls: string[] = []; @@ -180,6 +214,9 @@ function createSessionStub(messages = ['hello from launcher test']) { codexMessages.push(message); }, sendUserMessage(_text: string) {}, + sendCodexHistoryItem(item: unknown) { + historyItems.push(item); + }, sendSessionEvent(event: { type: string; [key: string]: unknown }) { sessionEvents.push(event); } @@ -193,10 +230,15 @@ function createSessionStub(messages = ['hello from launcher test']) { codexArgs: undefined, codexCliOverrides: undefined, sessionId: null as string | null, + forkSessionId: opts?.forkSessionId ?? undefined, + forkHistory: opts?.forkHistory ?? undefined, thinking: false, getPermissionMode() { return 'default' as const; }, + getCollaborationMode() { + return 'default' as const; + }, setModel(nextModel: string | null) { currentModel = nextModel; }, @@ -230,6 +272,7 @@ function createSessionStub(messages = ['hello from launcher test']) { session, sessionEvents, codexMessages, + historyItems, thinkingChanges, foundSessionIds, resetThreadCalls, @@ -246,11 +289,14 @@ describe('codexRemoteLauncher', () => { harness.initializeCalls = []; harness.startThreadIds = []; harness.resumeThreadIds = []; + harness.resumeThreadCalls = []; harness.startTurnThreadIds = []; harness.interruptedTurns = []; harness.compactThreadIds = []; harness.suppressTurnCompletion = false; harness.remainingThreadSystemErrors = 0; + harness.forkCalls = []; + harness.resumeShouldThrow = null; }); it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => { @@ -305,7 +351,7 @@ describe('codexRemoteLauncher', () => { it('starts a fresh thread for the next queued message after thread-level systemError', async () => { harness.remainingThreadSystemErrors = 1; - const { session } = createSessionStub(['first message', 'second message']); + const { session } = createSessionStub({ messages: ['first message', 'second message'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -341,8 +387,83 @@ describe('codexRemoteLauncher', () => { })); }); + it('forks the source thread before starting a turn when forkSessionId is provided', async () => { + const { + session, + foundSessionIds + } = createSessionStub({ forkSessionId: 'thread-source' }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.forkCalls).toHaveLength(1); + expect(harness.forkCalls[0]).toMatchObject({ + threadId: 'thread-source', + cwd: '/tmp/hapi-update', + persistExtendedHistory: true + }); + expect(foundSessionIds).toContain('thread-forked'); + }); + + it('resumes a new thread with raw history for historical fork', async () => { + const forkHistory = [{ id: 'user-1', role: 'user' }]; + const { + session, + foundSessionIds + } = createSessionStub({ forkSessionId: 'thread-source', forkHistory }); + + const exitReason = await codexRemoteLauncher(session as never); + + expect(exitReason).toBe('exit'); + expect(harness.forkCalls).toHaveLength(0); + expect(harness.resumeThreadCalls).toHaveLength(1); + expect(harness.resumeThreadCalls[0]).toMatchObject({ + history: forkHistory, + cwd: '/tmp/hapi-update' + }); + expect(foundSessionIds[0]).toMatch(/^hapi-fork-/); + }); + + it('throws a descriptive error when thread/resume(history) is rejected by app-server', async () => { + harness.resumeShouldThrow = new Error('history not supported'); + const forkHistory = [{ id: 'user-1', role: 'user' }]; + const { session } = createSessionStub({ forkSessionId: 'thread-source', forkHistory }); + + await expect(codexRemoteLauncher(session as never)).rejects.toThrow( + /Codex historical fork failed: app-server rejected thread\/resume\(history\): history not supported/ + ); + expect(harness.resumeThreadCalls).toHaveLength(1); + }); + + it('records user raw history before completed app-server items', async () => { + const { + session, + historyItems + } = createSessionStub({ messages: ['hello from launcher test'], messageSeqs: [7] }); + + await codexRemoteLauncher(session as never); + + expect(historyItems).toHaveLength(2); + expect(historyItems[0]).toMatchObject({ + codexThreadId: 'thread-1', + itemId: 'hapi-user-7', + itemKind: 'user', + messageSeq: 7, + rawItem: { + id: 'hapi-user-7', + role: 'user' + } + }); + expect(historyItems[1]).toMatchObject({ + codexThreadId: 'thread-1', + turnId: 'turn-1', + itemId: 'cmd-1', + itemKind: 'tool' + }); + }); + it('clears codex thread state without starting a turn', async () => { - const { session, sessionEvents, resetThreadCalls } = createSessionStub(['/clear', 'next message']); + const { session, sessionEvents, resetThreadCalls } = createSessionStub({ messages: ['/clear', 'next message'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -359,7 +480,7 @@ describe('codexRemoteLauncher', () => { it('interrupts an in-flight turn before clearing codex thread state', async () => { harness.suppressTurnCompletion = true; - const { session, sessionEvents, resetThreadCalls } = createSessionStub(['first message', '/clear']); + const { session, sessionEvents, resetThreadCalls } = createSessionStub({ messages: ['first message', '/clear'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -376,7 +497,7 @@ describe('codexRemoteLauncher', () => { }); it('compacts the current thread without starting a turn', async () => { - const { session, sessionEvents } = createSessionStub(['first message', '/compact']); + const { session, sessionEvents } = createSessionStub({ messages: ['first message', '/compact'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -396,7 +517,7 @@ describe('codexRemoteLauncher', () => { it('interrupts an in-flight turn before compacting the current thread', async () => { harness.suppressTurnCompletion = true; - const { session, sessionEvents } = createSessionStub(['first message', '/compact']); + const { session, sessionEvents } = createSessionStub({ messages: ['first message', '/compact'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -413,7 +534,7 @@ describe('codexRemoteLauncher', () => { }); it('reports nothing to compact when no codex thread exists', async () => { - const { session, sessionEvents } = createSessionStub(['/compact']); + const { session, sessionEvents } = createSessionStub({ messages: ['/compact'] }); const exitReason = await codexRemoteLauncher(session as never); @@ -428,7 +549,7 @@ describe('codexRemoteLauncher', () => { }); it('rejects argument-bearing codex slash commands without starting a turn', async () => { - const { session, sessionEvents } = createSessionStub(['/compact now']); + const { session, sessionEvents } = createSessionStub({ messages: ['/compact now'] }); const exitReason = await codexRemoteLauncher(session as never); diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 641530601c..8a1ea3a001 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -14,17 +14,19 @@ import type { 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 { buildThreadForkParams, buildThreadStartParams, buildTurnStartParams } from './utils/appServerConfig'; import { shouldIgnoreTerminalEvent } from './utils/terminalEventGuard'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; +import type { ResponseItem } from './appServerTypes'; import { RemoteLauncherBase, type RemoteLauncherDisplayContext, type RemoteLauncherExitReason } from '@/modules/common/remote/RemoteLauncherBase'; +import { isAbortError } from '@/utils/spawnWithAbort'; type HappyServer = Awaited>['server']; -type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string }; +type QueuedMessage = { message: string; mode: EnhancedMode; isolate: boolean; hash: string; messageSeqs: number[] }; class CodexRemoteLauncher extends RemoteLauncherBase { private readonly session: CodexSession; @@ -141,6 +143,77 @@ class CodexRemoteLauncher extends RemoteLauncherBase { return typeof value === 'string' && value.length > 0 ? value : null; }; + const extractHistoryItem = (params: unknown): Record | null => { + const record = asRecord(params); + if (!record) return null; + return asRecord(record.item) ?? record; + }; + + const extractHistoryItemId = (params: unknown, item: Record): string | null => { + const record = asRecord(params); + return asString(record?.itemId ?? record?.item_id ?? record?.id) + ?? asString(item.id ?? item.itemId ?? item.item_id); + }; + + const extractHistoryTurnId = (params: unknown): string | null => { + const record = asRecord(params); + return asString(record?.turnId ?? record?.turn_id) ?? this.currentTurnId; + }; + + const classifyHistoryItem = (item: Record): 'user' | 'assistant' | 'tool' | 'event' | 'unknown' => { + if (item.role === 'user') return 'user'; + if (item.role === 'assistant') return 'assistant'; + const rawType = asString(item.type)?.toLowerCase() ?? ''; + if (rawType.includes('tool') || rawType.includes('function') || rawType.includes('command')) return 'tool'; + if (rawType.includes('message') || rawType.includes('reasoning')) return 'assistant'; + if (rawType.includes('event')) return 'event'; + return 'unknown'; + }; + + const recordHistoryItem = (input: { + threadId: string; + turnId?: string | null; + itemId: string; + itemKind: 'user' | 'assistant' | 'tool' | 'event' | 'unknown'; + messageSeq?: number | null; + rawItem: ResponseItem; + }): void => { + const historySender = (session.client as unknown as { sendCodexHistoryItem?: typeof session.client.sendCodexHistoryItem }).sendCodexHistoryItem; + if (!historySender) { + return; + } + historySender.call(session.client, { + codexThreadId: input.threadId, + turnId: input.turnId ?? null, + itemId: input.itemId, + itemKind: input.itemKind, + messageSeq: input.messageSeq ?? null, + rawItem: input.rawItem + }); + }; + + const buildUserHistoryItem = (itemId: string, message: string): ResponseItem => ({ + id: itemId, + type: 'message', + role: 'user', + content: [{ type: 'input_text', text: message }] + }); + + const recordCompletedHistoryItem = (method: string, params: unknown): void => { + if (method !== 'item/completed' || !this.currentThreadId) return; + const item = extractHistoryItem(params); + if (!item) return; + const itemId = extractHistoryItemId(params, item); + if (!itemId) return; + recordHistoryItem({ + threadId: this.currentThreadId, + turnId: extractHistoryTurnId(params), + itemId, + itemKind: classifyHistoryItem(item), + rawItem: item + }); + }; + const applyResolvedModel = (value: unknown): string | undefined => { const resolvedModel = asString(value) ?? undefined; if (!resolvedModel) { @@ -567,6 +640,7 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }); appServerClient.setNotificationHandler((method, params) => { + recordCompletedHistoryItem(method, params); const events = appServerEventConverter.handleNotification(method, params); for (const event of events) { const eventRecord = asRecord(event) ?? { type: undefined }; @@ -598,6 +672,23 @@ class CodexRemoteLauncher extends RemoteLauncherBase { session.sendSessionEvent({ type: 'ready' }); }; + const buildInitialMode = (): EnhancedMode => { + const rawPermissionMode = session.getPermissionMode(); + const permissionMode = rawPermissionMode === 'default' + || rawPermissionMode === 'read-only' + || rawPermissionMode === 'safe-yolo' + || rawPermissionMode === 'yolo' + ? rawPermissionMode + : 'default'; + const rawCollaborationMode = session.getCollaborationMode?.(); + const collaborationMode = rawCollaborationMode === 'plan' ? 'plan' : 'default'; + return { + permissionMode, + model: session.getModel() ?? undefined, + collaborationMode + }; + }; + await appServerClient.connect(); await appServerClient.initialize({ clientInfo: { @@ -612,6 +703,74 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let hasThread = false; let pending: QueuedMessage | null = null; + if (session.forkHistory) { + const threadParams = buildThreadStartParams({ + cwd: session.path, + mode: buildInitialMode(), + mcpServers, + cliOverrides: session.codexCliOverrides + }); + // Contract: Codex app-server `thread/resume` accepts an unknown threadId together with a + // `history` array and creates a fresh thread seeded with that history. The synthetic id + // here is required so we can later disambiguate this thread from the source. If a future + // app-server release rejects unknown ids, this is the call that breaks first. + const historicalThreadId = `hapi-fork-${randomUUID()}`; + let resumeResponse: unknown; + try { + resumeResponse = await appServerClient.resumeThread({ + threadId: historicalThreadId, + history: session.forkHistory, + ...threadParams + }, { + signal: this.abortController.signal + }); + } catch (error) { + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Codex historical fork failed: app-server rejected thread/resume(history): ${detail}`); + } + const resumeRecord = asRecord(resumeResponse); + const resumeThread = resumeRecord ? asRecord(resumeRecord.thread) : null; + const resumedThreadId = asString(resumeThread?.id); + if (!resumedThreadId) { + throw new Error('Codex historical fork failed: app-server thread/resume(history) did not return thread.id'); + } + this.currentThreadId = resumedThreadId; + session.onSessionFound(resumedThreadId); + hasThread = true; + applyResolvedModel(resumeRecord?.model); + sendReady(); + } else if (session.forkSessionId) { + let forkResponse: unknown; + try { + forkResponse = await appServerClient.forkThread(buildThreadForkParams({ + threadId: session.forkSessionId, + cwd: session.path, + mode: buildInitialMode(), + mcpServers, + cliOverrides: session.codexCliOverrides + }), { + signal: this.abortController.signal + }); + } catch (error) { + if (isAbortError(error)) { + throw error; + } + const detail = error instanceof Error ? error.message : String(error); + throw new Error(`Codex fork failed: app-server rejected thread/fork: ${detail}`); + } + const forkRecord = asRecord(forkResponse); + const forkThread = forkRecord ? asRecord(forkRecord.thread) : null; + const forkedThreadId = asString(forkThread?.id); + if (!forkedThreadId) { + throw new Error('Codex fork failed: app-server thread/fork did not return thread.id'); + } + this.currentThreadId = forkedThreadId; + session.onSessionFound(forkedThreadId); + hasThread = true; + applyResolvedModel(forkRecord?.model); + sendReady(); + } + clearReadyAfterTurnTimer = () => { if (!readyAfterTurnTimer) { return; @@ -788,12 +947,42 @@ class CodexRemoteLauncher extends RemoteLauncherBase { cliOverrides: session.codexCliOverrides }); + const forkCandidate = session.forkSessionId; const resumeCandidate = session.sessionId && session.sessionId !== invalidThreadId ? session.sessionId : null; let threadId: string | null = null; - if (resumeCandidate) { + if (forkCandidate) { + try { + const forkResponse = await appServerClient.forkThread(buildThreadForkParams({ + threadId: forkCandidate, + cwd: session.path, + mode: message.mode, + mcpServers, + cliOverrides: session.codexCliOverrides + }), { + signal: this.abortController.signal + }); + const forkRecord = asRecord(forkResponse); + const forkThread = forkRecord ? asRecord(forkRecord.thread) : null; + threadId = asString(forkThread?.id); + applyResolvedModel(forkRecord?.model); + logger.debug(`[Codex] Forked app-server thread ${forkCandidate} -> ${threadId ?? 'unknown'}`); + } catch (error) { + // Surface the fork failure to the user. Falling back silently to startThread + // would lose the source thread's context without any indication. + if (isAbortError(error)) { + throw error; + } + const detail = error instanceof Error ? error.message : String(error); + const message = `Fork failed (${forkCandidate}): ${detail}`; + logger.warn(`[Codex] ${message}`); + messageBuffer.addMessage(message, 'status'); + session.sendSessionEvent({ type: 'message', message }); + throw error; + } + } else if (resumeCandidate) { try { const resumeResponse = await appServerClient.resumeThread({ threadId: resumeCandidate, @@ -850,6 +1039,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { }, cliOverrides: session.codexCliOverrides }); + const messageSeq = message.messageSeqs.length === 1 ? message.messageSeqs[0] : null; + if (typeof messageSeq === 'number' && this.currentThreadId) { + const itemId = `hapi-user-${messageSeq}`; + recordHistoryItem({ + threadId: this.currentThreadId, + itemId, + itemKind: 'user', + messageSeq, + rawItem: buildUserHistoryItem(itemId, message.message) + }); + } turnInFlight = true; allowAnonymousTerminalEvent = false; const turnResponse = await appServerClient.startTurn(turnParams, { @@ -867,12 +1067,12 @@ class CodexRemoteLauncher extends RemoteLauncherBase { } } catch (error) { logger.warn('Error in codex session:', error); - const isAbortError = error instanceof Error && error.name === 'AbortError'; + const aborted = isAbortError(error); turnInFlight = false; allowAnonymousTerminalEvent = false; this.currentTurnId = null; - if (isAbortError) { + if (aborted) { messageBuffer.addMessage('Aborted by user', 'status'); session.sendSessionEvent({ type: 'message', message: 'Aborted by user' }); } else { diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 223807b1c7..18d2a20499 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -6,7 +6,7 @@ import { codexLocalLauncher } from './codexLocalLauncher'; import { codexRemoteLauncher } from './codexRemoteLauncher'; import { ApiClient, ApiSessionClient } from '@/lib'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; -import type { ReasoningEffort } from './appServerTypes'; +import type { ReasoningEffort, ResponseItem } from './appServerTypes'; import type { CodexCollaborationMode, CodexPermissionMode } from '@hapi/protocol/types'; export type PermissionMode = CodexPermissionMode; @@ -33,6 +33,8 @@ interface LoopOptions { modelReasoningEffort?: ReasoningEffort; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; + forkSessionId?: string; + forkHistory?: ResponseItem[]; onSessionReady?: (session: CodexSession) => void; } @@ -53,6 +55,8 @@ export async function loop(opts: LoopOptions): Promise { startingMode, codexArgs: opts.codexArgs, codexCliOverrides: opts.codexCliOverrides, + forkSessionId: opts.forkSessionId, + forkHistory: opts.forkHistory, permissionMode: opts.permissionMode ?? 'default', model: opts.model, modelReasoningEffort: opts.modelReasoningEffort, diff --git a/cli/src/codex/runCodex.ts b/cli/src/codex/runCodex.ts index 90423028e6..c2b131d2cf 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -13,7 +13,7 @@ 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 { ReasoningEffort, ResponseItem } from './appServerTypes'; import { parseCodexSpecialCommand } from './codexSpecialCommands'; import { listSlashCommands } from '@/modules/common/slashCommands'; import { resolveCodexSlashCommand } from './utils/slashCommands'; @@ -27,6 +27,8 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + forkSessionId?: string; + forkHistory?: ResponseItem[]; model?: string; modelReasoningEffort?: ReasoningEffort; }): Promise { @@ -135,8 +137,10 @@ export async function runCodex(opts: { }; let userMessageChain: Promise = Promise.resolve(); - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { userMessageChain = userMessageChain.then(async () => { + const localId = meta.localId + const messageSeq = meta.seq ?? null try { syncCurrentConfigFromSession(); let text = message.content.text; @@ -186,10 +190,13 @@ export async function runCodex(opts: { collaborationMode: currentCollaborationMode }; if (isolatedCommandText) { - messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId); + messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId, messageSeq); return; } - messageQueue.push(text, enhancedMode, localId); + // Each user message starts its own turn so messageSeq → raw-history item is 1:1. + // Batching multiple messages into one turn would emit a single user item upstream + // and break historical-fork prefix reconstruction. + messageQueue.pushIsolated(text, enhancedMode, localId, messageSeq); } catch (error) { logger.debug('[Codex] Failed to handle user message', error); const enhancedMode: EnhancedMode = { @@ -198,7 +205,7 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode }; - messageQueue.push(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId); + messageQueue.pushIsolated(formatMessageWithAttachments(message.content.text, message.content.attachments), enhancedMode, localId, messageSeq); } }).catch((error) => { logger.debug('[Codex] User message handler chain failed', error); @@ -312,6 +319,8 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + forkSessionId: opts.forkSessionId, + forkHistory: opts.forkHistory, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 701c69e9c0..2741887646 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -5,6 +5,7 @@ import type { EnhancedMode, PermissionMode } from './loop'; import type { CodexCliOverrides } from './utils/codexCliOverrides'; import type { LocalLaunchExitReason } from '@/agent/localLaunchPolicy'; import type { Metadata, SessionModel, SessionModelReasoningEffort } from '@/api/types'; +import type { ResponseItem } from './appServerTypes'; type LocalLaunchFailure = { message: string; @@ -15,6 +16,8 @@ export class CodexSession extends AgentSessionBase { transcriptPath: string | null = null; readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; + readonly forkSessionId?: string; + readonly forkHistory?: ResponseItem[]; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -34,6 +37,8 @@ export class CodexSession extends AgentSessionBase { startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; + forkSessionId?: string; + forkHistory?: ResponseItem[]; permissionMode?: PermissionMode; model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; @@ -62,6 +67,8 @@ export class CodexSession extends AgentSessionBase { this.codexArgs = opts.codexArgs; this.codexCliOverrides = opts.codexCliOverrides; + this.forkSessionId = opts.forkSessionId; + this.forkHistory = opts.forkHistory; this.startedBy = opts.startedBy; this.startingMode = opts.startingMode; this.permissionMode = opts.permissionMode; diff --git a/cli/src/codex/utils/appServerConfig.test.ts b/cli/src/codex/utils/appServerConfig.test.ts index cd8df062cb..b95973f09f 100644 --- a/cli/src/codex/utils/appServerConfig.test.ts +++ b/cli/src/codex/utils/appServerConfig.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { buildThreadStartParams, buildTurnStartParams } from './appServerConfig'; +import { buildThreadForkParams, buildThreadStartParams, buildTurnStartParams } from './appServerConfig'; import { codexSystemPrompt } from './systemPrompt'; describe('appServerConfig', () => { @@ -97,6 +97,33 @@ describe('appServerConfig', () => { }); }); + it('builds fork params from thread config defaults', () => { + const params = buildThreadForkParams({ + threadId: 'thread-source', + cwd: '/workspace/project', + mode: { permissionMode: 'default', model: 'gpt-5.4', collaborationMode: 'default' }, + mcpServers + }); + + expect(params).toEqual({ + threadId: 'thread-source', + cwd: '/workspace/project', + approvalPolicy: 'on-request', + sandbox: 'workspace-write', + model: 'gpt-5.4', + baseInstructions: codexSystemPrompt, + developerInstructions: codexSystemPrompt, + config: { + 'mcp_servers.hapi': { + command: 'node', + args: ['mcp'] + }, + developer_instructions: codexSystemPrompt + }, + persistExtendedHistory: true + }); + }); + it('builds turn params with mode defaults', () => { const params = buildTurnStartParams({ threadId: 'thread-1', diff --git a/cli/src/codex/utils/appServerConfig.ts b/cli/src/codex/utils/appServerConfig.ts index 3df7083cc5..1d3c448f3b 100644 --- a/cli/src/codex/utils/appServerConfig.ts +++ b/cli/src/codex/utils/appServerConfig.ts @@ -6,6 +6,7 @@ import type { ApprovalPolicy, SandboxMode, SandboxPolicy, + ThreadForkParams, ThreadStartParams, TurnStartParams } from '../appServerTypes'; @@ -105,6 +106,37 @@ export function buildThreadStartParams(args: { return params; } +export function buildThreadForkParams(args: { + threadId: string; + cwd: string; + mode: EnhancedMode; + mcpServers: McpServersConfig; + cliOverrides?: CodexCliOverrides; + baseInstructions?: string; + developerInstructions?: string; +}): ThreadForkParams { + const startParams = buildThreadStartParams({ + cwd: args.cwd, + mode: args.mode, + mcpServers: args.mcpServers, + cliOverrides: args.cliOverrides, + baseInstructions: args.baseInstructions, + developerInstructions: args.developerInstructions + }); + + return { + threadId: args.threadId, + cwd: startParams.cwd, + approvalPolicy: startParams.approvalPolicy, + sandbox: startParams.sandbox, + config: startParams.config, + baseInstructions: startParams.baseInstructions, + developerInstructions: startParams.developerInstructions, + model: startParams.model, + persistExtendedHistory: true + }; +} + export function buildTurnStartParams(args: { threadId: string; message: string; diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index 8db32de440..624fb6e4e6 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -5,7 +5,8 @@ import { maybeAutoStartServer } from '@/utils/autoStartServer' import type { CommandDefinition } from './types' import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' -import type { ReasoningEffort } from '@/codex/appServerTypes' +import type { ReasoningEffort, ResponseItem } from '@/codex/appServerTypes' +import { readFile, rm } from 'node:fs/promises' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' function parseReasoningEffort(value: string): ReasoningEffort { @@ -34,6 +35,8 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + forkSessionId?: string + forkHistory?: ResponseItem[] model?: string modelReasoningEffort?: ReasoningEffort } = {} @@ -51,6 +54,15 @@ export const codexCommand: CommandDefinition = { i += 1 continue } + if (i === 0 && arg === 'fork') { + const candidate = commandArgs[i + 1] + if (!candidate || candidate.startsWith('-')) { + throw new Error('fork requires a session id') + } + options.forkSessionId = candidate + i += 1 + continue + } if (arg === '--started-by') { options.startedBy = commandArgs[++i] as 'runner' | 'terminal' } else if (arg === '--permission-mode') { @@ -76,6 +88,18 @@ export const codexCommand: CommandDefinition = { throw new Error('Missing --model-reasoning-effort value') } options.modelReasoningEffort = parseReasoningEffort(effort) + } else if (arg === '--fork-history-file') { + const file = commandArgs[++i] + if (!file) { + throw new Error('Missing --fork-history-file value') + } + const raw = await readFile(file, 'utf8') + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) { + throw new Error('--fork-history-file must contain a JSON array') + } + options.forkHistory = parsed as ResponseItem[] + void rm(file, { force: true }).catch(() => undefined) } else { unknownArgs.push(arg) } diff --git a/cli/src/cursor/runCursor.ts b/cli/src/cursor/runCursor.ts index 66ed2377c1..e24d6725a1 100644 --- a/cli/src/cursor/runCursor.ts +++ b/cli/src/cursor/runCursor.ts @@ -77,7 +77,8 @@ export async function runCursor(opts: { logger.debug(`[cursor] Synced session permission mode: ${currentPermissionMode}`); }; - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { + const localId = meta.localId const enhancedMode: EnhancedMode = { permissionMode: currentPermissionMode ?? 'default', model: currentModel diff --git a/cli/src/gemini/runGemini.ts b/cli/src/gemini/runGemini.ts index 612859ff2a..a41d0aae37 100644 --- a/cli/src/gemini/runGemini.ts +++ b/cli/src/gemini/runGemini.ts @@ -119,7 +119,8 @@ export async function runGemini(opts: { logger.debug(`[gemini] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${resolvedModel}`); }; - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { + const localId = meta.localId const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); const mode: GeminiMode = { permissionMode: currentPermissionMode, diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 6336f57dd8..e1005d05a4 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -3,6 +3,8 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + forkSessionId?: string + forkHistory?: unknown[] approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' model?: string diff --git a/cli/src/opencode/runOpencode.ts b/cli/src/opencode/runOpencode.ts index fd45b8b0de..80e1aa1e88 100644 --- a/cli/src/opencode/runOpencode.ts +++ b/cli/src/opencode/runOpencode.ts @@ -99,7 +99,8 @@ export async function runOpencode(opts: { logger.debug(`[opencode] Synced session config for keepalive: permissionMode=${currentPermissionMode}, model=${sessionModel ?? '(default)'}`); }; - session.onUserMessage((message, localId) => { + session.onUserMessage((message, meta) => { + const localId = meta.localId const formattedText = formatMessageWithAttachments(message.content.text, message.content.attachments); const mode: OpencodeMode = { permissionMode: currentPermissionMode, diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c0f02a4d4a..fd226e7fe9 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -378,7 +378,18 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro }; } + let forkHistoryFile: string | null = null; + let forkHistoryDir: string | null = null; + if (agent === 'codex' && Array.isArray(options.forkHistory)) { + forkHistoryDir = await fs.mkdtemp(join(os.tmpdir(), 'hapi-codex-history-')); + forkHistoryFile = join(forkHistoryDir, 'history.json'); + await fs.writeFile(forkHistoryFile, JSON.stringify(options.forkHistory)); + } + const args = buildCliArgs(agent, options, yolo); + if (forkHistoryFile) { + args.push('--fork-history-file', forkHistoryFile); + } // sessionId reserved for future use const MAX_TAIL_CHARS = 4000; @@ -491,6 +502,10 @@ export async function startRunner(options: { workspaceRoot?: string } = {}): Pro if (code !== 0 || signal) { logStderrTail(); } + // Child normally deletes this dir after reading; clean up here in case it crashed first. + if (forkHistoryDir) { + void fs.rm(forkHistoryDir, { recursive: true, force: true }).catch(() => undefined); + } const errorAwaiter = pidToErrorAwaiter.get(pid); if (errorAwaiter) { pidToErrorAwaiter.delete(pid); @@ -915,7 +930,9 @@ export function buildCliArgs( ? 'opencode' : 'claude'; const args = [agentCommand]; - if (options.resumeSessionId) { + if (options.forkSessionId && agent === 'codex') { + args.push('fork', options.forkSessionId); + } else if (options.resumeSessionId) { if (agent === 'codex') { args.push('resume', options.resumeSessionId); } else if (agent === 'cursor') { diff --git a/cli/src/utils/MessageQueue2.ts b/cli/src/utils/MessageQueue2.ts index ed4b514191..5abd0caf86 100644 --- a/cli/src/utils/MessageQueue2.ts +++ b/cli/src/utils/MessageQueue2.ts @@ -5,6 +5,7 @@ interface QueueItem { mode: T; modeHash: string; localId?: string; + messageSeq?: number | null; isolate?: boolean; // If true, this message must be processed alone } @@ -39,7 +40,7 @@ export class MessageQueue2 { /** * Push a message to the queue with a mode. */ - push(message: string, mode: T, localId?: string): void { + push(message: string, mode: T, localId?: string, messageSeq?: number | null): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -52,6 +53,7 @@ export class MessageQueue2 { mode, modeHash, localId, + messageSeq, isolate: false }); @@ -75,7 +77,7 @@ export class MessageQueue2 { * Push a message immediately without batching delay. * Does not clear the queue or enforce isolation. */ - pushImmediate(message: string, mode: T, localId?: string): void { + pushImmediate(message: string, mode: T, localId?: string, messageSeq?: number | null): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -88,6 +90,7 @@ export class MessageQueue2 { mode, modeHash, localId, + messageSeq, isolate: false }); @@ -112,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, localId?: string): void { + pushIsolateAndClear(message: string, mode: T, localId?: string, messageSeq?: number | null): void { if (this.closed) { throw new Error('Cannot push to closed queue'); } @@ -128,6 +131,7 @@ export class MessageQueue2 { mode, modeHash, localId, + messageSeq, isolate: true }); @@ -147,6 +151,40 @@ export class MessageQueue2 { logger.debug(`[MessageQueue2] pushIsolateAndClear() completed. Queue size: ${this.queue.length}`); } + /** + * Push an isolated message without dropping already queued messages. + */ + pushIsolated(message: string, mode: T, localId?: string, messageSeq?: number | null): void { + if (this.closed) { + throw new Error('Cannot push to closed queue'); + } + + const modeHash = this.modeHasher(mode); + logger.debug(`[MessageQueue2] pushIsolated() called with mode hash: ${modeHash}`); + + this.queue.push({ + message, + mode, + modeHash, + localId, + messageSeq, + isolate: true + }); + + if (this.onMessageHandler) { + this.onMessageHandler(message, mode); + } + + if (this.waiter) { + logger.debug(`[MessageQueue2] Notifying waiter for isolated message`); + const waiter = this.waiter; + this.waiter = null; + waiter(true); + } + + logger.debug(`[MessageQueue2] pushIsolated() completed. Queue size: ${this.queue.length}`); + } + /** * Push a message to the beginning of the queue with a mode. */ @@ -227,7 +265,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: string, mode: T, isolate: boolean, hash: string, messageSeqs: number[] } | null> { // If we have messages, return them immediately if (this.queue.length > 0) { return this.collectBatch(); @@ -251,7 +289,7 @@ 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: string, mode: T, hash: string, isolate: boolean, messageSeqs: number[] } | null { if (this.queue.length === 0) { return null; } @@ -259,6 +297,7 @@ export class MessageQueue2 { const firstItem = this.queue[0]; const sameModeMessages: string[] = []; const consumedLocalIds: string[] = []; + const consumedMessageSeqs: number[] = []; let mode = firstItem.mode; let isolate = firstItem.isolate ?? false; const targetModeHash = firstItem.modeHash; @@ -268,6 +307,7 @@ export class MessageQueue2 { const item = this.queue.shift()!; sameModeMessages.push(item.message); if (item.localId) consumedLocalIds.push(item.localId); + if (typeof item.messageSeq === 'number') consumedMessageSeqs.push(item.messageSeq); logger.debug(`[MessageQueue2] Collected isolated message with mode hash: ${targetModeHash}`); } else { // Collect all messages with the same mode until we hit an isolated message @@ -277,6 +317,7 @@ export class MessageQueue2 { const item = this.queue.shift()!; sameModeMessages.push(item.message); if (item.localId) consumedLocalIds.push(item.localId); + if (typeof item.messageSeq === 'number') consumedMessageSeqs.push(item.messageSeq); } logger.debug(`[MessageQueue2] Collected batch of ${sameModeMessages.length} messages with mode hash: ${targetModeHash}`); } @@ -292,7 +333,8 @@ export class MessageQueue2 { message: combinedMessage, mode, hash: targetModeHash, - isolate + isolate, + messageSeqs: consumedMessageSeqs }; } diff --git a/cli/src/utils/spawnWithAbort.ts b/cli/src/utils/spawnWithAbort.ts index ca2f591303..c1d520fd9f 100644 --- a/cli/src/utils/spawnWithAbort.ts +++ b/cli/src/utils/spawnWithAbort.ts @@ -6,7 +6,7 @@ import { killProcessByChildProcess } from '@/utils/process'; const DEFAULT_ABORT_EXIT_CODES = [130, 137, 143]; const DEFAULT_ABORT_SIGNALS: NodeJS.Signals[] = ['SIGTERM']; -const isAbortError = (error: unknown): boolean => { +export const isAbortError = (error: unknown): boolean => { if (!error || typeof error !== 'object') { return false; } diff --git a/docs/plans/hapi-feature-codex-fork/design.md b/docs/plans/hapi-feature-codex-fork/design.md new file mode 100644 index 0000000000..b76aab5ea4 --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/design.md @@ -0,0 +1,73 @@ +# Codex Fork Support Design Document + +## 1. Overview +- Business requirement: HAPI needs first-class Codex fork support instead of only supporting resume. +- Success criteria: + - CLI supports `hapi codex fork ` + - Hub exposes a fork API and spawns a new HAPI session instead of merging into the old one + - Web can trigger fork for Codex sessions and navigate to the new forked session + - Codex remote launcher uses app-server `thread/fork` +- Scope: + - `cli/` Codex launch + runner spawn arguments + - `hub/` session fork orchestration + HTTP route + - `web/` fork action + API client + +## 2. Module Interaction Flow +1. User selects fork in CLI or Web. +2. HAPI passes source Codex thread ID as `forkSessionId`. +3. CLI local mode runs `codex fork `. +4. CLI remote mode calls Codex app-server `thread/fork`. +5. Codex returns a new thread ID; HAPI stores it as the new session metadata `codexSessionId`. +6. Hub returns the new HAPI session id to the Web client. + +## 3. Module Design Details + +### CLI + +#### 0. Metadata +- Reuse existing `metadata.codexSessionId` +- No new persisted schema field required for parent thread tracking in this change set + +#### 1. Interfaces +- Add CLI parse path: `hapi codex fork ` +- Add app-server client method: `forkThread` +- Add thread fork param builder: `buildThreadForkParams` + +#### 2. Local / Remote Launch +- Local mode: + - invoke native `codex fork ` + - avoid pre-binding old thread id as current session id + - session scanner discovers the newly created Codex thread +- Remote mode: + - if `forkSessionId` exists, call `thread/fork` + - otherwise keep existing resume/start behavior + +### Hub + +#### 1. Interfaces +- Add `SyncEngine.forkSession(sessionId, namespace)` +- Add HTTP route: `POST /api/sessions/:id/fork` +- Extend machine spawn RPC payload with `forkSessionId` + +#### 2. Session Semantics +- Fork differs from resume: + - resume reactivates or merges into prior conversation identity + - fork always creates a new HAPI session +- Only Codex sessions are eligible + +### Web + +#### 1. Interfaces +- Add `api.forkSession(sessionId)` +- Add `useSessionActions().forkSession` +- Add session action menu item `Fork` + +#### 2. UX +- User clicks Fork on a Codex session +- Web calls `/fork` +- On success navigate to the new session detail page +- On failure show toast + +## 4. Notes +- Parent/child fork lineage is intentionally out of scope +- Automatic inactive-message send still uses resume; fork remains an explicit action diff --git a/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md new file mode 100644 index 0000000000..e4b8606074 --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md @@ -0,0 +1,3 @@ +cli +hub +web diff --git a/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md new file mode 100644 index 0000000000..6bd9c2438d --- /dev/null +++ b/docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md @@ -0,0 +1,16 @@ +# Codex Fork Support Proposal + +## Change Summary +- Wire Codex fork through CLI, runner, hub, and web +- Reuse upstream Codex app-server `thread/fork` +- Keep resume semantics unchanged + +## Affected Modules +- cli +- hub +- web + +## Behavior +- `hapi codex fork ` starts a new session forked from an existing Codex thread +- Web adds a Fork action for Codex sessions +- Hub exposes `POST /api/sessions/:id/fork` diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index b744debaf6..4fa0965866 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -168,6 +168,62 @@ describe('NotificationHub', () => { hub.stop() }) + it('suppresses the first ready notification for forked sessions', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 30 + }) + + const session = createSession({ id: 'forked-session' }) + engine.setSession(session) + + const readyEvent: SyncEvent = { + type: 'message-received', + sessionId: session.id, + message: { + id: 'message-1', + seq: 1, + localId: null, + createdAt: 0, + content: { + role: 'agent', + content: { + id: 'event-1', + type: 'event', + data: { type: 'ready' } + } + } + } + } + + engine.emit({ + type: 'session-forked', + sessionId: session.id, + sourceSessionId: 'source-session' + }) + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(0) + + await sleep(5) + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(0) + + await sleep(30) + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(0) + + engine.emit(readyEvent) + await sleep(5) + expect(channel.readySessions).toHaveLength(1) + + hub.stop() + }) + it('sends task notifications for task_notification system messages', async () => { const engine = new FakeSyncEngine() const channel = new StubChannel() diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index bfe109abf7..1630e0180e 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -10,6 +10,8 @@ export class NotificationHub { private readonly lastKnownRequests: Map> = new Map() private readonly notificationDebounce: Map = new Map() private readonly lastReadyNotificationAt: Map = new Map() + private readonly suppressReadyUntil: Map = new Map() + private readonly forkedBootstrapReadySessions: Set = new Set() private unsubscribeSyncEvents: (() => void) | null = null constructor( @@ -37,6 +39,8 @@ export class NotificationHub { this.notificationDebounce.clear() this.lastKnownRequests.clear() this.lastReadyNotificationAt.clear() + this.suppressReadyUntil.clear() + this.forkedBootstrapReadySessions.clear() } private handleSyncEvent(event: SyncEvent): void { @@ -55,6 +59,12 @@ export class NotificationHub { return } + if (event.type === 'session-forked') { + this.suppressReadyUntil.set(event.sessionId, Date.now() + this.readyCooldownMs) + this.forkedBootstrapReadySessions.add(event.sessionId) + return + } + if (event.type === 'session-ended' && event.sessionId) { if (event.reason === 'completed') { this.sendSessionCompletion(event.sessionId, event.reason).catch((error) => { @@ -67,6 +77,9 @@ export class NotificationHub { if (event.type === 'message-received' && event.sessionId) { const eventType = extractMessageEventType(event) if (eventType === 'ready') { + if (this.shouldSuppressForkedBootstrapReady(event.sessionId)) { + return + } this.sendReadyNotification(event.sessionId).catch((error) => { console.error('[NotificationHub] Failed to send ready notification:', error) }) @@ -89,6 +102,23 @@ export class NotificationHub { } this.lastKnownRequests.delete(sessionId) this.lastReadyNotificationAt.delete(sessionId) + this.suppressReadyUntil.delete(sessionId) + this.forkedBootstrapReadySessions.delete(sessionId) + } + + private shouldSuppressForkedBootstrapReady(sessionId: string): boolean { + const suppressUntil = this.suppressReadyUntil.get(sessionId) ?? 0 + if (Date.now() < suppressUntil) { + return true + } + + if (!this.forkedBootstrapReadySessions.has(sessionId)) { + return false + } + + this.forkedBootstrapReadySessions.delete(sessionId) + this.suppressReadyUntil.delete(sessionId) + return true } private getNotifiableSession(sessionId: string): Session | null { @@ -154,6 +184,14 @@ export class NotificationHub { } const now = Date.now() + const suppressUntil = this.suppressReadyUntil.get(sessionId) ?? 0 + if (now < suppressUntil) { + return + } + if (suppressUntil > 0) { + this.suppressReadyUntil.delete(sessionId) + } + const last = this.lastReadyNotificationAt.get(sessionId) ?? 0 if (now - last < this.readyCooldownMs) { return diff --git a/hub/src/socket/handlers/cli/sessionHandlers.ts b/hub/src/socket/handlers/cli/sessionHandlers.ts index 43ea720796..faa2d908eb 100644 --- a/hub/src/socket/handlers/cli/sessionHandlers.ts +++ b/hub/src/socket/handlers/cli/sessionHandlers.ts @@ -4,6 +4,7 @@ import { randomUUID } from 'node:crypto' import type { CodexCollaborationMode, PermissionMode } from '@hapi/protocol/types' import type { Store, StoredSession } from '../../../store' import type { SyncEvent } from '../../../sync/syncEngine' +import { mergeSessionMetadata } from '../../../sync/sessionMetadata' import { extractTodoWriteTodosFromMessageContent } from '../../../sync/todos' import { extractTeamStateFromMessageContent, applyTeamStateDelta } from '../../../sync/teams' import { extractBackgroundTaskDelta } from '../../../sync/backgroundTasks' @@ -43,6 +44,16 @@ const messageSchema = z.object({ localId: z.string().optional() }) +const codexHistoryItemSchema = z.object({ + sid: z.string(), + codexThreadId: z.string(), + turnId: z.string().nullable().optional(), + itemId: z.string(), + itemKind: z.enum(['user', 'assistant', 'tool', 'event', 'unknown']), + messageSeq: z.number().int().nullable().optional(), + rawItem: z.unknown() +}) + const updateMetadataSchema = z.object({ sid: z.string(), expectedVersion: z.number().int(), @@ -156,6 +167,30 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session }) }) + socket.on('codex-history-item', (data: unknown) => { + const parsed = codexHistoryItemSchema.safeParse(data) + if (!parsed.success) { + return + } + + const { sid } = parsed.data + const sessionAccess = resolveSessionAccess(sid) + if (!sessionAccess.ok) { + emitAccessError('session', sid, sessionAccess.reason) + return + } + + store.codexHistory.addItem({ + sessionId: sid, + codexThreadId: parsed.data.codexThreadId, + turnId: parsed.data.turnId ?? null, + itemId: parsed.data.itemId, + itemKind: parsed.data.itemKind, + messageSeq: parsed.data.messageSeq ?? null, + rawItem: parsed.data.rawItem + }) + }) + const handleUpdateMetadata: UpdateMetadataHandler = (data, cb) => { const parsed = updateMetadataSchema.safeParse(data) if (!parsed.success) { @@ -170,9 +205,12 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session return } + const currentSession = store.sessions.getSessionByNamespace(sid, sessionAccess.value.namespace) + const mergedMetadata = mergeSessionMetadata(currentSession?.metadata ?? null, metadata) + const result = store.sessions.updateSessionMetadata( sid, - metadata, + mergedMetadata, expectedVersion, sessionAccess.value.namespace ) @@ -192,7 +230,7 @@ export function registerSessionHandlers(socket: CliSocketWithData, deps: Session body: { t: 'update-session' as const, sid, - metadata: { version: result.version, value: metadata }, + metadata: { version: result.version, value: mergedMetadata }, agentState: null } } diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts new file mode 100644 index 0000000000..7259d077c1 --- /dev/null +++ b/hub/src/store/codexHistoryStore.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from 'bun:test' +import { Database } from 'bun:sqlite' + +import { Store } from './index' + +describe('CodexHistoryStore', () => { + it('creates v9 codex history table on fresh DB', () => { + const store = new Store(':memory:') + const db: Database = (store as any).db + const rows = db.prepare("PRAGMA table_info(codex_history_items)").all() as Array<{ name: string }> + const columns = rows.map((row) => row.name) + + expect(columns).toContain('session_id') + expect(columns).toContain('codex_thread_id') + expect(columns).toContain('turn_id') + expect(columns).toContain('item_id') + expect(columns).toContain('message_seq') + expect(columns).toContain('raw_item') + expect(columns).toContain('seq') + }) + + it('returns raw history prefix before the selected user message', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + turnId: 'turn-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'user-2', + itemKind: 'user', + messageSeq: 3, + rawItem: { id: 'user-2', role: 'user' } + }) + + expect(store.codexHistory.getPrefixBeforeMessageSeq(session.id, 3)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixBeforeMessageSeq(session.id, 1)).toEqual([]) + expect(store.codexHistory.getPrefixBeforeMessageSeq(session.id, 2)).toBeNull() + }) + + it('returns raw history through the selected user reply', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'user-2', + itemKind: 'user', + messageSeq: 3, + rawItem: { id: 'user-2', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'assistant-2', + itemKind: 'assistant', + rawItem: { id: 'assistant-2', role: 'assistant' } + }) + + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(session.id, 1)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(session.id, 3)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' }, + { id: 'user-2', role: 'user' }, + { id: 'assistant-2', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(session.id, 2)).toBeNull() + }) + + it('clones a raw history prefix and remaps user message seqs', () => { + const store = new Store(':memory:') + const source = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const target = store.sessions.getOrCreateSession('s2', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'user-2', + itemKind: 'user', + messageSeq: 3, + rawItem: { id: 'user-2', role: 'user' } + }) + + expect(store.codexHistory.clonePrefixThroughReplyForUserMessageSeq(source.id, target.id, 1, 4)).toBe(2) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 5)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 1)).toBeNull() + }) + + it('preserves cloned raw history when item ids collide', () => { + const store = new Store(':memory:') + const source = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const target = store.sessions.getOrCreateSession('s2', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'hapi-user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'source-user', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'source-assistant-1', + itemKind: 'assistant', + rawItem: { id: 'source-assistant', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: target.id, + codexThreadId: 'thread-2', + itemId: 'hapi-user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'target-user', role: 'user' } + }) + + expect(store.codexHistory.clonePrefixThroughReplyForUserMessageSeq(source.id, target.id, 1, 2)).toBe(2) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 3)).toEqual([ + { id: 'target-user', role: 'user' }, + { id: 'source-user', role: 'user' }, + { id: 'source-assistant', role: 'assistant' } + ]) + }) + + it('moves raw history between sessions and remaps target user message seqs', () => { + const store = new Store(':memory:') + const source = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const target = store.sessions.getOrCreateSession('s2', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: target.id, + codexThreadId: 'thread-2', + itemId: 'user-2', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-2', role: 'user' } + }) + + expect(store.codexHistory.moveSessionHistory(source.id, target.id, 2)).toBe(2) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(source.id, 1)).toBeNull() + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 1)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 3)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' }, + { id: 'user-2', role: 'user' } + ]) + }) + + it('preserves moved raw history when item ids collide', () => { + const store = new Store(':memory:') + const source = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const target = store.sessions.getOrCreateSession('s2', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'hapi-user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'source-user', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: source.id, + codexThreadId: 'thread-1', + itemId: 'source-assistant-1', + itemKind: 'assistant', + rawItem: { id: 'source-assistant', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: target.id, + codexThreadId: 'thread-2', + itemId: 'hapi-user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'target-user', role: 'user' } + }) + + expect(store.codexHistory.moveSessionHistory(source.id, target.id, 2)).toBe(2) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 1)).toEqual([ + { id: 'source-user', role: 'user' }, + { id: 'source-assistant', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 3)).toEqual([ + { id: 'source-user', role: 'user' }, + { id: 'source-assistant', role: 'assistant' }, + { id: 'target-user', role: 'user' } + ]) + }) + + it('remaps target raw history even when source has no raw rows', () => { + const store = new Store(':memory:') + const source = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const target = store.sessions.getOrCreateSession('s2', { flavor: 'codex' }, null, 'default') + + store.codexHistory.addItem({ + sessionId: target.id, + codexThreadId: 'thread-2', + itemId: 'hapi-user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'target-user', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: target.id, + codexThreadId: 'thread-2', + itemId: 'target-assistant-1', + itemKind: 'assistant', + rawItem: { id: 'target-assistant', role: 'assistant' } + }) + + expect(store.codexHistory.moveSessionHistory(source.id, target.id, 2)).toBe(0) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 1)).toBeNull() + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 3)).toEqual([ + { id: 'target-user', role: 'user' }, + { id: 'target-assistant', role: 'assistant' } + ]) + }) + + it('deletes codex history rows when deleting the session', () => { + const store = new Store(':memory:') + const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') + const db: Database = (store as any).db + + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + + expect(db.prepare('SELECT COUNT(*) AS count FROM codex_history_items').get()).toEqual({ count: 1 }) + store.sessions.deleteSession(session.id, 'default') + expect(db.prepare('SELECT COUNT(*) AS count FROM codex_history_items').get()).toEqual({ count: 0 }) + }) +}) diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts new file mode 100644 index 0000000000..b6e6f6ccce --- /dev/null +++ b/hub/src/store/codexHistoryStore.ts @@ -0,0 +1,330 @@ +import type { Database } from 'bun:sqlite' +import { randomUUID } from 'node:crypto' + +import { safeJsonParse } from './json' + +export type CodexHistoryItemKind = 'user' | 'assistant' | 'tool' | 'event' | 'unknown' + +export type AddCodexHistoryItemInput = { + sessionId: string + codexThreadId: string + turnId?: string | null + itemId: string + itemKind: CodexHistoryItemKind + messageSeq?: number | null + rawItem: unknown +} + +type CodexHistoryRow = { + id?: string + codex_thread_id: string + turn_id: string | null + item_id: string + item_kind: CodexHistoryItemKind + message_seq: number | null + raw_item: string + seq: number + created_at: number +} + +export class CodexHistoryStore { + private readonly db: Database + + constructor(db: Database) { + this.db = db + } + + addItem(input: AddCodexHistoryItemInput): void { + // Compute seq atomically inside the INSERT so two concurrent addItem calls cannot read the + // same MAX(seq) and produce duplicate seq values for one session. + const result = this.db.prepare(` + INSERT OR IGNORE INTO codex_history_items ( + id, session_id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + ) + SELECT + @id, @session_id, @codex_thread_id, @turn_id, @item_id, @item_kind, @message_seq, @raw_item, + COALESCE((SELECT MAX(seq) FROM codex_history_items WHERE session_id = @session_id), 0) + 1, + @created_at + `).run({ + id: randomUUID(), + session_id: input.sessionId, + codex_thread_id: input.codexThreadId, + turn_id: input.turnId ?? null, + item_id: input.itemId, + item_kind: input.itemKind, + message_seq: input.messageSeq ?? null, + raw_item: JSON.stringify(input.rawItem), + created_at: Date.now() + }) + if (result.changes === 0) { + // INSERT OR IGNORE swallowed the row — most likely a duplicate (session_id, item_id), + // but any constraint failure lands here. Log enough to disambiguate during a later post-mortem. + console.warn(`[CodexHistoryStore] addItem inserted 0 rows sessionId=${input.sessionId} itemId=${input.itemId} (duplicate or constraint violation)`) + } + } + + getPrefixThroughReplyForUserMessageSeq(sessionId: string, messageSeq: number): unknown[] | null { + const cut = this.db.prepare(` + SELECT seq + FROM codex_history_items + WHERE session_id = ? + AND message_seq = ? + AND item_kind = 'user' + ORDER BY seq ASC + LIMIT 1 + `).get(sessionId, messageSeq) as { seq: number } | undefined + + if (!cut) { + return null + } + + const nextUser = this.db.prepare(` + SELECT seq + FROM codex_history_items + WHERE session_id = ? + AND item_kind = 'user' + AND seq > ? + ORDER BY seq ASC + LIMIT 1 + `).get(sessionId, cut.seq) as { seq: number } | undefined + + const beforeClause = nextUser ? 'AND seq < @nextUserSeq' : '' + const rows = this.db.prepare(` + SELECT seq, raw_item + FROM codex_history_items + WHERE session_id = @sessionId + ${beforeClause} + ORDER BY seq ASC + `).all({ + sessionId, + nextUserSeq: nextUser?.seq ?? null + }) as Array<{ seq: number; raw_item: string }> + + return parsePrefixRows(rows, sessionId) + } + + getPrefixBeforeMessageSeq(sessionId: string, beforeSeq: number): unknown[] | null { + const cut = this.db.prepare(` + SELECT seq + FROM codex_history_items + WHERE session_id = ? + AND message_seq = ? + AND item_kind = 'user' + ORDER BY seq ASC + LIMIT 1 + `).get(sessionId, beforeSeq) as { seq: number } | undefined + + if (!cut) { + return null + } + + const rows = this.db.prepare(` + SELECT seq, raw_item + FROM codex_history_items + WHERE session_id = ? + AND seq < ? + ORDER BY seq ASC + `).all(sessionId, cut.seq) as Array<{ seq: number; raw_item: string }> + + return parsePrefixRows(rows, sessionId) + } + + cloneSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number { + const rows = this.db.prepare(` + SELECT id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + FROM codex_history_items + WHERE session_id = ? + ORDER BY seq ASC + `).all(fromSessionId) as CodexHistoryRow[] + + return this.cloneRows(toSessionId, rows, messageSeqOffset) + } + + clonePrefixThroughReplyForUserMessageSeq( + fromSessionId: string, + toSessionId: string, + messageSeq: number, + messageSeqOffset: number + ): number { + const cut = this.db.prepare(` + SELECT seq + FROM codex_history_items + WHERE session_id = ? + AND message_seq = ? + AND item_kind = 'user' + ORDER BY seq ASC + LIMIT 1 + `).get(fromSessionId, messageSeq) as { seq: number } | undefined + + if (!cut) return 0 + + const nextUser = this.db.prepare(` + SELECT seq + FROM codex_history_items + WHERE session_id = ? + AND item_kind = 'user' + AND seq > ? + ORDER BY seq ASC + LIMIT 1 + `).get(fromSessionId, cut.seq) as { seq: number } | undefined + + const beforeClause = nextUser ? 'AND seq < @nextUserSeq' : '' + const rows = this.db.prepare(` + SELECT id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + FROM codex_history_items + WHERE session_id = @fromSessionId + ${beforeClause} + ORDER BY seq ASC + `).all({ + fromSessionId, + nextUserSeq: nextUser?.seq ?? null + }) as CodexHistoryRow[] + + return this.cloneRows(toSessionId, rows, messageSeqOffset) + } + + moveSessionHistory(fromSessionId: string, toSessionId: string, targetMessageSeqOffset: number): number { + if (fromSessionId === toSessionId) return 0 + + const sourceRows = this.db.prepare(` + SELECT id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + FROM codex_history_items + WHERE session_id = ? + ORDER BY seq ASC + `).all(fromSessionId) as Array + + try { + this.db.exec('BEGIN') + + if (targetMessageSeqOffset !== 0) { + this.db.prepare(` + UPDATE codex_history_items + SET message_seq = CASE + WHEN message_seq IS NULL THEN NULL + ELSE message_seq + ? + END + WHERE session_id = ? + `).run(targetMessageSeqOffset, toSessionId) + } + + if (sourceRows.length === 0) { + this.db.exec('COMMIT') + return 0 + } + + const sourceMaxSeq = sourceRows[sourceRows.length - 1]?.seq ?? 0 + if (sourceMaxSeq > 0) { + this.db.prepare(` + UPDATE codex_history_items + SET seq = seq + ? + WHERE session_id = ? + `).run(sourceMaxSeq, toSessionId) + } + + const existingItemIds = new Set( + (this.db.prepare( + 'SELECT item_id FROM codex_history_items WHERE session_id = ?' + ).all(toSessionId) as Array<{ item_id: string }>).map((row) => row.item_id) + ) + + const insert = this.db.prepare(` + INSERT INTO codex_history_items ( + id, session_id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + ) VALUES ( + @id, @session_id, @codex_thread_id, @turn_id, @item_id, @item_kind, @message_seq, @raw_item, @seq, @created_at + ) + `) + + let moved = 0 + for (const row of sourceRows) { + const itemId = existingItemIds.has(row.item_id) + ? `${row.item_id}:moved:${row.id}` + : row.item_id + insert.run({ + id: randomUUID(), + session_id: toSessionId, + codex_thread_id: row.codex_thread_id, + turn_id: row.turn_id, + item_id: itemId, + item_kind: row.item_kind, + message_seq: row.message_seq, + raw_item: row.raw_item, + seq: row.seq, + created_at: row.created_at + }) + existingItemIds.add(itemId) + moved += 1 + } + + this.db.prepare( + 'DELETE FROM codex_history_items WHERE session_id = ?' + ).run(fromSessionId) + + this.db.exec('COMMIT') + return moved + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + + private cloneRows(toSessionId: string, rows: CodexHistoryRow[], messageSeqOffset: number): number { + if (rows.length === 0) return 0 + + const targetMaxSeq = (this.db.prepare( + 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM codex_history_items WHERE session_id = ?' + ).get(toSessionId) as { maxSeq: number } | undefined)?.maxSeq ?? 0 + + const existingItemIds = new Set( + (this.db.prepare( + 'SELECT item_id FROM codex_history_items WHERE session_id = ?' + ).all(toSessionId) as Array<{ item_id: string }>).map((row) => row.item_id) + ) + + const insert = this.db.prepare(` + INSERT INTO codex_history_items ( + id, session_id, codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + ) VALUES ( + @id, @session_id, @codex_thread_id, @turn_id, @item_id, @item_kind, @message_seq, @raw_item, @seq, @created_at + ) + `) + + let cloned = 0 + for (const row of rows) { + const itemId = existingItemIds.has(row.item_id) + ? `${row.item_id}:cloned:${row.id ?? randomUUID()}` + : row.item_id + insert.run({ + id: randomUUID(), + session_id: toSessionId, + codex_thread_id: row.codex_thread_id, + turn_id: row.turn_id, + item_id: itemId, + item_kind: row.item_kind, + message_seq: row.message_seq == null ? null : row.message_seq + messageSeqOffset, + raw_item: row.raw_item, + seq: targetMaxSeq + row.seq, + created_at: row.created_at + }) + existingItemIds.add(itemId) + cloned += 1 + } + + return cloned + } +} + +// Throw on unparseable rows — forwarding null into thread/resume(history) would corrupt the prefix. +function parsePrefixRows(rows: Array<{ seq: number; raw_item: string }>, sessionId: string): unknown[] { + const items: unknown[] = [] + for (const row of rows) { + const parsed = safeJsonParse(row.raw_item) + if (parsed === null) { + const message = `[CodexHistoryStore] Corrupt history row sessionId=${sessionId} seq=${row.seq}` + console.error(message) + throw new Error(message) + } + items.push(parsed) + } + return items +} diff --git a/hub/src/store/index.ts b/hub/src/store/index.ts index f8c08cde3c..2d554d5c97 100644 --- a/hub/src/store/index.ts +++ b/hub/src/store/index.ts @@ -7,6 +7,7 @@ import { MessageStore } from './messageStore' import { PushStore } from './pushStore' import { SessionStore } from './sessionStore' import { UserStore } from './userStore' +import { CodexHistoryStore } from './codexHistoryStore' export type { StoredMachine, @@ -21,14 +22,16 @@ export { MessageStore } from './messageStore' export { PushStore } from './pushStore' export { SessionStore } from './sessionStore' export { UserStore } from './userStore' +export { CodexHistoryStore } from './codexHistoryStore' -const SCHEMA_VERSION: number = 8 +const SCHEMA_VERSION: number = 9 const REQUIRED_TABLES = [ 'sessions', 'machines', 'messages', 'users', - 'push_subscriptions' + 'push_subscriptions', + 'codex_history_items' ] as const export class Store { @@ -40,6 +43,7 @@ export class Store { readonly messages: MessageStore readonly users: UserStore readonly push: PushStore + readonly codexHistory: CodexHistoryStore constructor(dbPath: string) { this.dbPath = dbPath @@ -81,6 +85,7 @@ export class Store { this.messages = new MessageStore(this.db) this.users = new UserStore(this.db) this.push = new PushStore(this.db) + this.codexHistory = new CodexHistoryStore(this.db) } private initSchema(): void { @@ -97,6 +102,7 @@ export class Store { 5: () => this.migrateFromV5ToV6(), 6: () => this.migrateFromV6ToV7(), 7: () => this.migrateFromV7ToV8(), + 8: () => this.migrateFromV8ToV9(), }) if (currentVersion === 0) { @@ -220,6 +226,26 @@ export class Store { UNIQUE(namespace, endpoint) ); CREATE INDEX IF NOT EXISTS idx_push_subscriptions_namespace ON push_subscriptions(namespace); + + CREATE TABLE IF NOT EXISTS codex_history_items ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + codex_thread_id TEXT NOT NULL, + turn_id TEXT, + item_id TEXT NOT NULL, + item_kind TEXT NOT NULL, + message_seq INTEGER, + raw_item TEXT NOT NULL, + seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_codex_history_items_session_item + ON codex_history_items(session_id, item_id); + CREATE INDEX IF NOT EXISTS idx_codex_history_items_session_seq + ON codex_history_items(session_id, seq); + CREATE INDEX IF NOT EXISTS idx_codex_history_items_session_message_seq + ON codex_history_items(session_id, message_seq); `) } @@ -377,6 +403,30 @@ export class Store { `) } + private migrateFromV8ToV9(): void { + this.db.exec(` + CREATE TABLE IF NOT EXISTS codex_history_items ( + id TEXT PRIMARY KEY, + session_id TEXT NOT NULL, + codex_thread_id TEXT NOT NULL, + turn_id TEXT, + item_id TEXT NOT NULL, + item_kind TEXT NOT NULL, + message_seq INTEGER, + raw_item TEXT NOT NULL, + seq INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY (session_id) REFERENCES sessions(id) ON DELETE CASCADE + ); + CREATE UNIQUE INDEX IF NOT EXISTS idx_codex_history_items_session_item + ON codex_history_items(session_id, item_id); + CREATE INDEX IF NOT EXISTS idx_codex_history_items_session_seq + ON codex_history_items(session_id, seq); + CREATE INDEX IF NOT EXISTS idx_codex_history_items_session_message_seq + ON codex_history_items(session_id, message_seq); + `) + } + private getSessionColumnNames(): Set { const rows = this.db.prepare('PRAGMA table_info(sessions)').all() as Array<{ name: string }> return new Set(rows.map((row) => row.name)) diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 3dec8002b9..dbe53779d9 100644 --- a/hub/src/store/messageStore.ts +++ b/hub/src/store/messageStore.ts @@ -1,7 +1,7 @@ import type { Database } from 'bun:sqlite' import type { StoredMessage } from './types' -import { addMessage, getMessages, getMessagesAfter, getMessagesByPosition, getUninvokedLocalMessages, markMessagesInvoked, mergeSessionMessages } from './messages' +import { addMessage, cloneSessionMessages, getMessageBySeq, getMessages, getMessagesAfter, getMessagesByPosition, getNextUserMessageSeq, getPreviousUserMessageSeq, getUninvokedLocalMessages, markMessagesInvoked, mergeSessionMessages } from './messages' export class MessageStore { private readonly db: Database @@ -30,6 +30,18 @@ export class MessageStore { return getUninvokedLocalMessages(this.db, sessionId) } + getMessageBySeq(sessionId: string, seq: number): StoredMessage | null { + return getMessageBySeq(this.db, sessionId, seq) + } + + getNextUserMessageSeq(sessionId: string, afterSeq: number): number | null { + return getNextUserMessageSeq(this.db, sessionId, afterSeq) + } + + getPreviousUserMessageSeq(sessionId: string, beforeSeq: number): number | null { + return getPreviousUserMessageSeq(this.db, sessionId, beforeSeq) + } + markMessagesInvoked(sessionId: string, localIds: string[], invokedAt: number): void { markMessagesInvoked(this.db, sessionId, localIds, invokedAt) } @@ -37,4 +49,8 @@ export class MessageStore { mergeSessionMessages(fromSessionId: string, toSessionId: string): { moved: number; oldMaxSeq: number; newMaxSeq: number } { return mergeSessionMessages(this.db, fromSessionId, toSessionId) } + + cloneSessionMessages(fromSessionId: string, toSessionId: string, beforeSeq?: number): { cloned: number; sourceMaxSeq: number; targetMaxSeq: number } { + return cloneSessionMessages(this.db, fromSessionId, toSessionId, beforeSeq) + } } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index c315ea7eba..a87ac0fa47 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -1,5 +1,6 @@ import type { Database } from 'bun:sqlite' import { randomUUID } from 'node:crypto' +import { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { StoredMessage } from './types' import { safeJsonParse } from './json' @@ -114,6 +115,44 @@ export function getMessagesAfter( return rows.map(toStoredMessage) } +export function getNextUserMessageSeq( + db: Database, + sessionId: string, + afterSeq: number +): number | null { + const rows = db.prepare( + 'SELECT * FROM messages WHERE session_id = ? AND seq > ? ORDER BY seq ASC' + ).all(sessionId, afterSeq) as DbMessageRow[] + + for (const row of rows) { + const record = unwrapRoleWrappedRecordEnvelope(safeJsonParse(row.content)) + if (record?.role === 'user') { + return row.seq + } + } + + return null +} + +export function getPreviousUserMessageSeq( + db: Database, + sessionId: string, + beforeSeq: number +): number | null { + const rows = db.prepare( + 'SELECT * FROM messages WHERE session_id = ? AND seq < ? ORDER BY seq DESC' + ).all(sessionId, beforeSeq) as DbMessageRow[] + + for (const row of rows) { + const record = unwrapRoleWrappedRecordEnvelope(safeJsonParse(row.content)) + if (record?.role === 'user') { + return row.seq + } + } + + return null +} + /** Paginate messages by COALESCE(invoked_at, created_at) DESC, seq DESC. * Used for V8 byPosition mode. Results are returned in ascending display order. */ export function getMessagesByPosition( @@ -164,6 +203,13 @@ export function getMaxSeq(db: Database, sessionId: string): number { return row?.maxSeq ?? 0 } +export function getMessageBySeq(db: Database, sessionId: string, seq: number): StoredMessage | null { + const row = db.prepare( + 'SELECT * FROM messages WHERE session_id = ? AND seq = ? LIMIT 1' + ).get(sessionId, seq) as DbMessageRow | undefined + return row ? toStoredMessage(row) : null +} + /** Mark messages as invoked at the given server timestamp. * Only updates rows whose local_id is in localIds. * First-write-wins: rows with a non-NULL invoked_at are not updated. A duplicate @@ -241,3 +287,85 @@ export function mergeSessionMessages( throw error } } + +export function cloneSessionMessages( + db: Database, + fromSessionId: string, + toSessionId: string, + beforeSeq?: number +): { cloned: number; sourceMaxSeq: number; targetMaxSeq: number } { + if (fromSessionId === toSessionId) { + return { cloned: 0, sourceMaxSeq: 0, targetMaxSeq: getMaxSeq(db, toSessionId) } + } + + const sourceRows = beforeSeq === undefined + ? db.prepare( + 'SELECT * FROM messages WHERE session_id = ? ORDER BY seq ASC' + ).all(fromSessionId) as DbMessageRow[] + : db.prepare( + 'SELECT * FROM messages WHERE session_id = ? AND seq < ? ORDER BY seq ASC' + ).all(fromSessionId, beforeSeq) as DbMessageRow[] + + if (sourceRows.length === 0) { + return { cloned: 0, sourceMaxSeq: 0, targetMaxSeq: getMaxSeq(db, toSessionId) } + } + + const sourceMaxSeq = sourceRows[sourceRows.length - 1]?.seq ?? 0 + + try { + db.exec('BEGIN') + + // Cloned rows preserve relative ordering by reusing the source row's seq offset by + // targetMaxSeq. Source-side gaps (from a prior session merge) are inherited as-is; sort + // order remains correct but cloned rows are not guaranteed to be contiguous in seq. + const targetMaxSeq = getMaxSeq(db, toSessionId) + const existingLocalIds = new Set( + (db.prepare( + 'SELECT local_id FROM messages WHERE session_id = ? AND local_id IS NOT NULL' + ).all(toSessionId) as Array<{ local_id: string }>).map((row) => row.local_id) + ) + + const insert = db.prepare(` + INSERT INTO messages ( + id, session_id, content, created_at, seq, local_id, invoked_at + ) VALUES ( + @id, @session_id, @content, @created_at, @seq, @local_id, @invoked_at + ) + `) + + for (const row of sourceRows) { + const localId = row.local_id && !existingLocalIds.has(row.local_id) + ? row.local_id + : null + + if (localId) { + existingLocalIds.add(localId) + } + + // Cloned messages are history, never queued work. Force a non-null invoked_at so a + // pending user message on the source (local_id set, invoked_at null) does not get + // re-delivered to the CLI on the forked session via getUninvokedLocalMessages. + const invokedAt = row.invoked_at ?? row.created_at + + insert.run({ + id: randomUUID(), + session_id: toSessionId, + content: row.content, + created_at: row.created_at, + seq: targetMaxSeq + row.seq, + local_id: localId, + invoked_at: invokedAt + }) + } + + db.exec('COMMIT') + return { + cloned: sourceRows.length, + sourceMaxSeq, + targetMaxSeq: targetMaxSeq + sourceMaxSeq + } + } catch (error) { + db.exec('ROLLBACK') + throw error + } +} diff --git a/hub/src/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 977b4ead4e..8975481bc2 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -142,14 +142,30 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + forkHistory?: unknown[] ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { try { const result = await this.machineRpc( machineId, 'spawn-happy-session', - { type: 'spawn-in-directory', directory, agent, model, modelReasoningEffort, yolo, sessionType, worktreeName, resumeSessionId, effort, permissionMode } + { + type: 'spawn-in-directory', + directory, + agent, + model, + modelReasoningEffort, + yolo, + sessionType, + worktreeName, + resumeSessionId, + forkSessionId, + forkHistory, + effort, + permissionMode + } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 982c78a0de..a35883c70b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -3,6 +3,7 @@ import type { CodexCollaborationMode, PermissionMode, Session } from '@hapi/prot import type { Store } from '../store' import { clampAliveTime } from './aliveTime' import { EventPublisher } from './eventPublisher' +import { mergeSessionMetadata } from './sessionMetadata' import { extractTodoWriteTodosFromMessageContent, TodosSchema } from './todos' import { extractBackgroundTaskDelta } from './backgroundTasks' @@ -458,6 +459,50 @@ export class SessionCache { this.refreshSession(sessionId) } + async inheritSessionMetadata(sourceSessionId: string, targetSessionId: string): Promise { + const source = this.sessions.get(sourceSessionId) ?? this.refreshSession(sourceSessionId) + if (!source) { + throw new Error('Session not found') + } + + // Retry on version-mismatch: by the time the fork's target session has become active, the CLI + // has typically already emitted update-metadata events that bumped metadataVersion. Re-read the + // latest snapshot and re-merge so a concurrent CLI write does not silently drop inheritance. + // Small backoff between attempts gives any in-flight CLI write room to land before we reread. + for (let attempt = 0; attempt < 3; attempt += 1) { + if (attempt > 0) { + await new Promise((resolve) => setTimeout(resolve, 25 * attempt)) + } + const target = this.refreshSession(targetSessionId) + if (!target) { + throw new Error('Session not found') + } + + const mergedMetadata = mergeSessionMetadata(source.metadata ?? null, target.metadata ?? null) + if (mergedMetadata === target.metadata) { + return + } + + const result = this.store.sessions.updateSessionMetadata( + targetSessionId, + mergedMetadata, + target.metadataVersion, + target.namespace, + { touchUpdatedAt: false } + ) + + if (result.result === 'success') { + this.refreshSession(targetSessionId) + return + } + if (result.result === 'error') { + throw new Error('Failed to inherit session metadata') + } + } + + throw new Error('Session was modified concurrently. Please try again.') + } + async deleteSession(sessionId: string): Promise { const session = this.sessions.get(sessionId) if (!session) { @@ -514,6 +559,10 @@ export class SessionCache { } const movedMessages = this.store.messages.mergeSessionMessages(oldSessionId, newSessionId) + const targetMessageSeqOffset = movedMessages.oldMaxSeq > 0 && movedMessages.newMaxSeq > 0 + ? movedMessages.oldMaxSeq + : 0 + this.store.codexHistory.moveSessionHistory(oldSessionId, newSessionId, targetMessageSeqOffset) if (movedMessages.moved > 0) { if (!options.deleteOldSession) { this.publisher.emit({ type: 'messages-invalidated', sessionId: oldSessionId, namespace }) @@ -521,7 +570,7 @@ export class SessionCache { this.publisher.emit({ type: 'messages-invalidated', sessionId: newSessionId, namespace }) } - const mergedMetadata = this.mergeSessionMetadata(oldStored.metadata, newStored.metadata) + const mergedMetadata = 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) @@ -629,51 +678,6 @@ export class SessionCache { this.publisher.emit({ type: 'session-updated', sessionId: newSessionId, data: refreshed }) } } - - private mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { - if (!oldMetadata || typeof oldMetadata !== 'object') { - return newMetadata - } - if (!newMetadata || typeof newMetadata !== 'object') { - return oldMetadata - } - - const oldObj = oldMetadata as Record - const newObj = newMetadata as Record - const merged: Record = { ...newObj } - let changed = false - - if (typeof oldObj.name === 'string' && typeof newObj.name !== 'string') { - merged.name = oldObj.name - changed = true - } - - const oldSummary = oldObj.summary as { text?: unknown; updatedAt?: unknown } | undefined - const newSummary = newObj.summary as { text?: unknown; updatedAt?: unknown } | undefined - const oldUpdatedAt = typeof oldSummary?.updatedAt === 'number' ? oldSummary.updatedAt : null - const newUpdatedAt = typeof newSummary?.updatedAt === 'number' ? newSummary.updatedAt : null - if (oldUpdatedAt !== null && (newUpdatedAt === null || oldUpdatedAt > newUpdatedAt)) { - merged.summary = oldSummary - changed = true - } - - if (oldObj.worktree && !newObj.worktree) { - merged.worktree = oldObj.worktree - changed = true - } - - if (typeof oldObj.path === 'string' && typeof newObj.path !== 'string') { - merged.path = oldObj.path - changed = true - } - if (typeof oldObj.host === 'string' && typeof newObj.host !== 'string') { - merged.host = oldObj.host - changed = true - } - - return changed ? merged : newMetadata - } - private mergeAgentState(oldState: unknown | null, newState: unknown | null): unknown | null { if (oldState === null) return newState if (newState === null) return oldState diff --git a/hub/src/sync/sessionMetadata.test.ts b/hub/src/sync/sessionMetadata.test.ts new file mode 100644 index 0000000000..7a93694401 --- /dev/null +++ b/hub/src/sync/sessionMetadata.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'bun:test' +import { mergeSessionMetadata } from './sessionMetadata' + +describe('mergeSessionMetadata', () => { + it('preserves custom name when new metadata omits it', () => { + expect(mergeSessionMetadata( + { path: '/tmp/project', host: 'localhost', name: '自定义标题' }, + { path: '/tmp/project', host: 'localhost', codexSessionId: 'thread-2' } + )).toEqual({ + path: '/tmp/project', + host: 'localhost', + codexSessionId: 'thread-2', + name: '自定义标题' + }) + }) + + it('preserves newer summary when new metadata omits it', () => { + expect(mergeSessionMetadata( + { + path: '/tmp/project', + host: 'localhost', + summary: { text: '原标题', updatedAt: 200 } + }, + { path: '/tmp/project', host: 'localhost', codexSessionId: 'thread-2' } + )).toEqual({ + path: '/tmp/project', + host: 'localhost', + codexSessionId: 'thread-2', + summary: { text: '原标题', updatedAt: 200 } + }) + }) + + it('keeps newer incoming summary when it is fresher', () => { + expect(mergeSessionMetadata( + { + path: '/tmp/project', + host: 'localhost', + summary: { text: '旧标题', updatedAt: 100 } + }, + { + path: '/tmp/project', + host: 'localhost', + summary: { text: '新标题', updatedAt: 200 } + } + )).toEqual({ + path: '/tmp/project', + host: 'localhost', + summary: { text: '新标题', updatedAt: 200 } + }) + }) +}) diff --git a/hub/src/sync/sessionMetadata.ts b/hub/src/sync/sessionMetadata.ts new file mode 100644 index 0000000000..fbc4ee63de --- /dev/null +++ b/hub/src/sync/sessionMetadata.ts @@ -0,0 +1,43 @@ +export function mergeSessionMetadata(oldMetadata: unknown | null, newMetadata: unknown | null): unknown | null { + if (!oldMetadata || typeof oldMetadata !== 'object') { + return newMetadata + } + if (!newMetadata || typeof newMetadata !== 'object') { + return oldMetadata + } + + const oldObj = oldMetadata as Record + const newObj = newMetadata as Record + const merged: Record = { ...newObj } + let changed = false + + if (typeof oldObj.name === 'string' && typeof newObj.name !== 'string') { + merged.name = oldObj.name + changed = true + } + + const oldSummary = oldObj.summary as { text?: unknown; updatedAt?: unknown } | undefined + const newSummary = newObj.summary as { text?: unknown; updatedAt?: unknown } | undefined + const oldUpdatedAt = typeof oldSummary?.updatedAt === 'number' ? oldSummary.updatedAt : null + const newUpdatedAt = typeof newSummary?.updatedAt === 'number' ? newSummary.updatedAt : null + if (oldUpdatedAt !== null && (newUpdatedAt === null || oldUpdatedAt > newUpdatedAt)) { + merged.summary = oldSummary + changed = true + } + + if (oldObj.worktree && !newObj.worktree) { + merged.worktree = oldObj.worktree + changed = true + } + + if (typeof oldObj.path === 'string' && typeof newObj.path !== 'string') { + merged.path = oldObj.path + changed = true + } + if (typeof oldObj.host === 'string' && typeof newObj.host !== 'string') { + merged.host = oldObj.host + changed = true + } + + return changed ? merged : newMetadata +} diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index f743fe656a..578f78f352 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -473,6 +473,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _forkSessionId?: string, effort?: string ) => { capturedModel = model @@ -589,7 +590,8 @@ describe('session model', () => { _yolo?: boolean, _sessionType?: 'simple' | 'worktree', _worktreeName?: string, - resumeSessionId?: string + resumeSessionId?: string, + _forkSessionId?: string ) => { capturedResumeSessionId = resumeSessionId return { type: 'success', sessionId: session.id } @@ -654,6 +656,7 @@ describe('session model', () => { _sessionType?: string, _worktreeName?: string, _resumeSessionId?: string, + _forkSessionId?: string, _effort?: string, permissionMode?: string ) => { @@ -671,6 +674,369 @@ describe('session model', () => { } }) + it('passes fork session ID to rpc gateway when forking codex session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-fork', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4', + undefined, + 'xhigh' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let capturedForkSessionId: string | undefined + let capturedModel: string | undefined + let capturedModelReasoningEffort: string | undefined + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + model?: string, + modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: 'simple' | 'worktree', + _worktreeName?: string, + _resumeSessionId?: string, + forkSessionId?: string + ) => { + capturedModel = model + capturedModelReasoningEffort = modelReasoningEffort + capturedForkSessionId = forkSessionId + const forkedSession = engine.getOrCreateSession( + 'forked-session', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2' + }, + null, + 'default', + model + ) + return { type: 'success', sessionId: forkedSession.id } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result.type).toBe('success') + expect(capturedForkSessionId).toBe('codex-thread-1') + expect(capturedModel).toBe('gpt-5.4') + expect(capturedModelReasoningEffort).toBe('xhigh') + } finally { + engine.stop() + } + }) + + it('passes raw history through the selected user reply for historical codex fork', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-history-fork', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'first' } }) + store.messages.addMessage(session.id, { role: 'assistant', content: { type: 'text', text: 'answer' } }) + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'second' } }) + store.messages.addMessage(session.id, { role: 'assistant', content: { type: 'text', text: 'second answer' } }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'user-3', + itemKind: 'user', + messageSeq: 3, + rawItem: { id: 'user-3', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'assistant-3', + itemKind: 'assistant', + rawItem: { id: 'assistant-3', role: 'assistant' } + }) + + let capturedForkSessionId: string | undefined + let capturedForkHistory: unknown[] | undefined + let spawnCount = 0 + let forkedSessionId = '' + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: 'simple' | 'worktree', + _worktreeName?: string, + _resumeSessionId?: string, + forkSessionId?: string, + _effort?: string, + _permissionMode?: string, + forkHistory?: unknown[] + ) => { + capturedForkSessionId = forkSessionId + capturedForkHistory = forkHistory + spawnCount += 1 + const forkedSession = engine.getOrCreateSession( + `forked-session-history-point-${spawnCount}`, + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: `codex-thread-fork-${spawnCount}` + }, + null, + 'default' + ) + forkedSessionId = forkedSession.id + return { type: 'success', sessionId: forkedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default', { beforeSeq: 2 }) + + expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(capturedForkSessionId).toBeUndefined() + expect(capturedForkHistory as unknown).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.messages.getMessages(forkedSessionId, 10).map((message) => message.content)).toEqual([ + { role: 'user', content: { type: 'text', text: 'first' } }, + { role: 'assistant', content: { type: 'text', text: 'answer' } } + ]) + + const firstForkedSessionId = forkedSessionId + capturedForkSessionId = undefined + capturedForkHistory = undefined + + const chainedResult = await engine.forkSession(firstForkedSessionId, 'default', { beforeSeq: 2 }) + + expect(chainedResult).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(forkedSessionId).not.toBe(firstForkedSessionId) + expect(capturedForkSessionId).toBeUndefined() + expect(capturedForkHistory as unknown).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + } finally { + engine.stop() + } + }) + + it('forks before a selected user message by using the previous completed turn', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-history-fork-before-user', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'first' } }) + store.messages.addMessage(session.id, { role: 'assistant', content: { type: 'text', text: 'answer' } }) + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'second' } }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) + store.codexHistory.addItem({ + sessionId: session.id, + codexThreadId: 'codex-thread-1', + itemId: 'user-2', + itemKind: 'user', + messageSeq: 3, + rawItem: { id: 'user-2', role: 'user' } + }) + + let capturedForkHistory: unknown[] | undefined + let forkedSessionId = '' + ;(engine as any).rpcGateway.spawnSession = async ( + _machineId: string, + _directory: string, + _agent: string, + _model?: string, + _modelReasoningEffort?: string, + _yolo?: boolean, + _sessionType?: 'simple' | 'worktree', + _worktreeName?: string, + _resumeSessionId?: string, + _forkSessionId?: string, + _effort?: string, + _permissionMode?: string, + forkHistory?: unknown[] + ) => { + capturedForkHistory = forkHistory + const forkedSession = engine.getOrCreateSession( + 'forked-session-before-user', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2' + }, + null, + 'default' + ) + forkedSessionId = forkedSession.id + return { type: 'success', sessionId: forkedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default', { beforeSeq: 3 }) + + expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(capturedForkHistory).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.messages.getMessages(forkedSessionId, 10).map((message) => message.content)).toEqual([ + { role: 'user', content: { type: 'text', text: 'first' } }, + { role: 'assistant', content: { type: 'text', text: 'answer' } } + ]) + } finally { + engine.stop() + } + }) + + it('rejects historical fork for non-user cut points or sessions without raw history', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-history-fork-unavailable', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default' + ) + store.messages.addMessage(session.id, { role: 'user', content: { type: 'text', text: 'first' } }) + store.messages.addMessage(session.id, { role: 'assistant', content: { type: 'text', text: 'answer' } }) + + expect(await engine.forkSession(session.id, 'default', { beforeSeq: 2 })).toMatchObject({ + type: 'error', + code: 'fork_unavailable', + message: 'Historical fork is only supported for sessions started with the new Codex history pipeline' + }) + expect(await engine.forkSession(session.id, 'default', { beforeSeq: 1 })).toMatchObject({ + type: 'error', + code: 'fork_unavailable', + message: 'No earlier history to fork from' + }) + } finally { + engine.stop() + } + }) + describe('session dedup by agent session ID', () => { it('merges duplicate when codexSessionId collides', async () => { const store = new Store(':memory:') @@ -684,8 +1050,23 @@ describe('session model', () => { 'default' ) - // Add a message to s1 - store.messages.addMessage(s1.id, { type: 'text', text: 'hello from s1' }, 'local-1') + store.messages.addMessage(s1.id, { role: 'user', content: { type: 'text', text: 'hello from s1' } }) + store.messages.addMessage(s1.id, { role: 'assistant', content: { type: 'text', text: 'answer from s1' } }) + store.codexHistory.addItem({ + sessionId: s1.id, + codexThreadId: 'thread-X', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'user-1', role: 'user' } + }) + store.codexHistory.addItem({ + sessionId: s1.id, + codexThreadId: 'thread-X', + itemId: 'assistant-1', + itemKind: 'assistant', + rawItem: { id: 'assistant-1', role: 'assistant' } + }) const s2 = cache.getOrCreateSession( 'tag-2', @@ -693,6 +1074,15 @@ describe('session model', () => { null, 'default' ) + store.messages.addMessage(s2.id, { role: 'user', content: { type: 'text', text: 'hello from s2' } }) + store.codexHistory.addItem({ + sessionId: s2.id, + codexThreadId: 'thread-X', + itemId: 'user-1', + itemKind: 'user', + messageSeq: 1, + rawItem: { id: 'target-user-1', role: 'user' } + }) expect(s1.id).not.toBe(s2.id) @@ -703,6 +1093,15 @@ describe('session model', () => { const messages = store.messages.getMessages(s2.id, 100) expect(messages.length).toBeGreaterThanOrEqual(1) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(s2.id, 1)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' } + ]) + expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(s2.id, 3)).toEqual([ + { id: 'user-1', role: 'user' }, + { id: 'assistant-1', role: 'assistant' }, + { id: 'target-user-1', role: 'user' } + ]) }) it('preserves sessions with different agent session IDs', async () => { @@ -1003,4 +1402,214 @@ describe('session model', () => { expect(state.completedRequests?.['req-1']).toBeDefined() }) }) + + it('clones existing messages into the forked session', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-fork-history', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + store.messages.addMessage(session.id, { + role: 'user', + content: { type: 'text', text: 'old user message' } + }) + store.messages.addMessage(session.id, { + role: 'assistant', + content: { type: 'text', text: 'old assistant message' } + }) + + let forkedSessionId = '' + ;(engine as any).rpcGateway.spawnSession = async () => { + const forkedSession = engine.getOrCreateSession( + 'forked-session-history', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2' + }, + null, + 'default', + 'gpt-5.4' + ) + forkedSessionId = forkedSession.id + return { type: 'success', sessionId: forkedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(store.messages.getMessages(forkedSessionId, 10).map((message) => message.content)).toEqual([ + { role: 'user', content: { type: 'text', text: 'old user message' } }, + { role: 'assistant', content: { type: 'text', text: 'old assistant message' } } + ]) + expect(store.messages.getMessages(session.id, 10).map((message) => message.content)).toEqual([ + { role: 'user', content: { type: 'text', text: 'old user message' } }, + { role: 'assistant', content: { type: 'text', text: 'old assistant message' } } + ]) + } finally { + engine.stop() + } + }) + + it('preserves custom session title when forking', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-fork-title', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + name: '我的自定义标题' + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let forkedSessionId = '' + ;(engine as any).rpcGateway.spawnSession = async () => { + const forkedSession = engine.getOrCreateSession( + 'forked-session-title', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2' + }, + null, + 'default', + 'gpt-5.4' + ) + forkedSessionId = forkedSession.id + return { type: 'success', sessionId: forkedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(engine.getSession(forkedSessionId)?.metadata?.name).toBe('我的自定义标题') + expect(store.sessions.getSession(forkedSessionId)?.metadata).toMatchObject({ + name: '我的自定义标题' + }) + } finally { + engine.stop() + } + }) + + it('preserves summary title when forking', async () => { + const store = new Store(':memory:') + const engine = new SyncEngine( + store, + {} as never, + new RpcRegistry(), + { broadcast() {} } as never + ) + + try { + const session = engine.getOrCreateSession( + 'session-codex-fork-summary', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-1', + summary: { + text: '自动标题', + updatedAt: 123456 + } + }, + null, + 'default', + 'gpt-5.4' + ) + engine.getOrCreateMachine( + 'machine-1', + { host: 'localhost', platform: 'linux', happyCliVersion: '0.1.0' }, + null, + 'default' + ) + engine.handleMachineAlive({ machineId: 'machine-1', time: Date.now() }) + + let forkedSessionId = '' + ;(engine as any).rpcGateway.spawnSession = async () => { + const forkedSession = engine.getOrCreateSession( + 'forked-session-summary', + { + path: '/tmp/project', + host: 'localhost', + machineId: 'machine-1', + flavor: 'codex', + codexSessionId: 'codex-thread-2' + }, + null, + 'default', + 'gpt-5.4' + ) + forkedSessionId = forkedSession.id + return { type: 'success', sessionId: forkedSessionId } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) + expect(engine.getSession(forkedSessionId)?.metadata?.summary?.text).toBe('自动标题') + expect(store.sessions.getSession(forkedSessionId)?.metadata).toMatchObject({ + summary: { + text: '自动标题', + updatedAt: 123456 + } + }) + } finally { + engine.stop() + } + }) }) diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c3c59bd33c..a395b0d520 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 { unwrapRoleWrappedRecordEnvelope } from '@hapi/protocol/messages' import type { Server } from 'socket.io' import type { Store } from '../store' import type { RpcRegistry } from '../socket/rpcRegistry' @@ -50,7 +51,12 @@ export type ResumeSessionResult = | { type: 'success'; sessionId: string } | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } +export type ForkSessionResult = + | { type: 'success'; sessionId: string; warnings?: string[] } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'fork_unavailable' | 'fork_failed' } + export class SyncEngine { + private readonly store: Store private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache private readonly machineCache: MachineCache @@ -64,6 +70,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) @@ -402,8 +409,10 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string, - permissionMode?: PermissionMode + permissionMode?: PermissionMode, + forkHistory?: unknown[] ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { return await this.rpcGateway.spawnSession( machineId, @@ -415,8 +424,10 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + forkSessionId, effort, - permissionMode + permissionMode, + forkHistory ) } @@ -489,6 +500,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + undefined, session.effort ?? undefined, effectivePermissionMode ) @@ -540,6 +552,198 @@ export class SyncEngine { } } + async forkSession(sessionId: string, namespace: string, opts?: { beforeSeq?: number }): Promise { + const access = this.sessionCache.resolveSessionAccess(sessionId, namespace) + if (!access.ok) { + return { + type: 'error', + message: access.reason === 'access-denied' ? 'Session access denied' : 'Session not found', + code: access.reason === 'access-denied' ? 'access_denied' : 'session_not_found' + } + } + + const session = access.session + const metadata = session.metadata + if (!metadata || typeof metadata.path !== 'string') { + return { type: 'error', message: 'Session metadata missing path', code: 'fork_unavailable' } + } + + if (metadata.flavor !== 'codex') { + return { type: 'error', message: 'Fork is only supported for Codex sessions', code: 'fork_unavailable' } + } + + let forkHistory: unknown[] | undefined + let cloneBeforeSeq: number | undefined + let historicalForkUserMessageSeq: number | undefined + if (opts?.beforeSeq !== undefined) { + if (!Number.isInteger(opts.beforeSeq) || opts.beforeSeq <= 0) { + return { type: 'error', message: 'beforeSeq must be a positive integer', code: 'fork_unavailable' } + } + const cutMessage = this.store.messages.getMessageBySeq(sessionId, opts.beforeSeq) + const record = cutMessage ? unwrapRoleWrappedRecordEnvelope(cutMessage.content) : null + const userMessageSeq = (() => { + if (!cutMessage || !record) return null + if (record.role === 'user') { + return this.store.messages.getPreviousUserMessageSeq(sessionId, opts.beforeSeq) + } + if (record.role === 'agent' || record.role === 'assistant') { + return this.store.messages.getPreviousUserMessageSeq(sessionId, opts.beforeSeq) + } + return null + })() + if (!userMessageSeq) { + return { type: 'error', message: 'No earlier history to fork from', code: 'fork_unavailable' } + } + const prefix = this.store.codexHistory.getPrefixThroughReplyForUserMessageSeq(sessionId, userMessageSeq) + if (!prefix) { + return { + type: 'error', + message: 'Historical fork is only supported for sessions started with the new Codex history pipeline', + code: 'fork_unavailable' + } + } + if (prefix.length === 0) { + // Defensive: a non-null but empty prefix means we located a user-message cut point + // but found zero raw history rows up to it — should be impossible by construction + // and indicates corruption rather than an old session. + return { + type: 'error', + message: 'Codex history prefix is empty; refusing to fork from missing history', + code: 'fork_unavailable' + } + } + // Conservative cap below Socket.IO's 1 MiB default to leave room for the rest of the + // spawn payload (mcp config, permission mode, model, etc.). Without a guard the spawn + // RPC silently fails as an opaque socket timeout. + const FORK_HISTORY_MAX_BYTES = 512 * 1024 + const prefixBytes = Buffer.byteLength(JSON.stringify(prefix), 'utf8') + if (prefixBytes > FORK_HISTORY_MAX_BYTES) { + return { + type: 'error', + message: `Historical fork payload too large (${prefixBytes} bytes, max ${FORK_HISTORY_MAX_BYTES})`, + code: 'fork_unavailable' + } + } + forkHistory = prefix + cloneBeforeSeq = this.store.messages.getNextUserMessageSeq(sessionId, userMessageSeq) ?? undefined + historicalForkUserMessageSeq = userMessageSeq + } + + // Whole-session fork needs the source's codex thread id; historical fork carries the prefix + // inline and does not. + const forkToken = metadata.codexSessionId + if (!forkHistory && !forkToken) { + return { type: 'error', message: 'Fork session ID unavailable', code: 'fork_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 + })() + + if (!targetMachine) { + return { type: 'error', message: 'No machine online', code: 'no_machine_online' } + } + + const spawnResult = await this.rpcGateway.spawnSession( + targetMachine.id, + metadata.path, + 'codex', + session.model ?? undefined, + session.modelReasoningEffort ?? undefined, + undefined, + undefined, + undefined, + undefined, + forkHistory ? undefined : forkToken, + session.effort ?? undefined, + session.permissionMode ?? undefined, + forkHistory + ) + + if (spawnResult.type !== 'success') { + return { type: 'error', message: spawnResult.message, code: 'fork_failed' } + } + + const becameActive = await this.waitForSessionActive(spawnResult.sessionId) + if (!becameActive) { + // Spawn succeeded but the CLI never went active. Avoid the partial-success leak: do NOT + // clone messages, inherit metadata, or emit session-forked into a session the user + // can't actually use yet (skipping the emit also prevents notificationHub from leaking + // a never-cleaned entry for a session that may never emit session-end). + return { type: 'error', message: 'Session failed to become active', code: 'fork_failed' } + } + + // Emit session-forked only after active. The CLI's `ready` event always lags `session-alive` + // by the time it takes to set up the codex thread, so notificationHub still gets the + // suppression entry installed before the ready arrives. + this.eventPublisher.emit({ + type: 'session-forked', + sessionId: spawnResult.sessionId, + sourceSessionId: sessionId, + namespace + }) + + // Best-effort post-conditions. If either fails the session itself is still valid; surface a + // warning back to the caller (and a log line) rather than reporting fork_failed. + const warnings: string[] = [] + let clonedMessageSeqOffset: number | null = null + try { + // Observability for the residual ordering race: codex sessions don't normally write to + // the messages table before the first user turn, so the target should be empty here. + // If a future CLI change starts writing earlier, cloned history will land *after* those + // rows in the timeline; logging makes that regression obvious instead of silent. + const cloneResult = this.store.messages.cloneSessionMessages(sessionId, spawnResult.sessionId, cloneBeforeSeq) + clonedMessageSeqOffset = cloneResult.targetMaxSeq - cloneResult.sourceMaxSeq + const targetMaxSeqBefore = cloneResult.targetMaxSeq - cloneResult.sourceMaxSeq + if (targetMaxSeqBefore > 0) { + console.warn(`[SyncEngine] Forked session ${spawnResult.sessionId} already had ${targetMaxSeqBefore} messages before clone; cloned history will appear after them.`) + } + } catch (error) { + console.error(`[SyncEngine] Failed to clone messages into forked session ${spawnResult.sessionId}:`, error) + warnings.push('history could not be cloned') + } + if (clonedMessageSeqOffset != null) { + try { + if (historicalForkUserMessageSeq !== undefined) { + this.store.codexHistory.clonePrefixThroughReplyForUserMessageSeq( + sessionId, + spawnResult.sessionId, + historicalForkUserMessageSeq, + clonedMessageSeqOffset + ) + } else { + this.store.codexHistory.cloneSessionHistory(sessionId, spawnResult.sessionId, clonedMessageSeqOffset) + } + } catch (error) { + console.error(`[SyncEngine] Failed to clone Codex history into forked session ${spawnResult.sessionId}:`, error) + warnings.push('raw history could not be cloned') + } + } + try { + await this.sessionCache.inheritSessionMetadata(sessionId, spawnResult.sessionId) + } catch (error) { + console.error(`[SyncEngine] Failed to inherit metadata into forked session ${spawnResult.sessionId}:`, error) + warnings.push('title could not be inherited') + } + + return warnings.length > 0 + ? { type: 'success', sessionId: spawnResult.sessionId, warnings } + : { type: 'success', sessionId: spawnResult.sessionId } + } + async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { const start = Date.now() while (Date.now() - start < timeoutMs) { diff --git a/hub/src/web/routes/machines.ts b/hub/src/web/routes/machines.ts index 9f201fbe39..6b94088019 100644 --- a/hub/src/web/routes/machines.ts +++ b/hub/src/web/routes/machines.ts @@ -61,6 +61,7 @@ export function createMachinesRoutes(getSyncEngine: () => SyncEngine | null): Ho parsed.data.sessionType, parsed.data.worktreeName, undefined, + undefined, parsed.data.effort ) return c.json(result) diff --git a/hub/src/web/routes/sessions.test.ts b/hub/src/web/routes/sessions.test.ts index fc29e2931b..3e347f4dd3 100644 --- a/hub/src/web/routes/sessions.test.ts +++ b/hub/src/web/routes/sessions.test.ts @@ -52,6 +52,7 @@ function createSession(overrides?: Partial): Session { function createApp(session: Session, opts?: { resumeSession?: (sessionId: string, namespace: string, resumeOpts?: { permissionMode?: string }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> + forkSession?: (sessionId: string, namespace: string, forkOpts?: { beforeSeq?: number }) => Promise<{ type: string; sessionId?: string; message?: string; code?: string }> }) { const applySessionConfigCalls: Array<[string, Record]> = [] const applySessionConfig = async (sessionId: string, config: Record) => { @@ -72,12 +73,14 @@ function createApp(session: Session, opts?: { currentModelId: 'ollama/exaone:4.5-33b-q8' }) const resumeSession = opts?.resumeSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) + const forkSession = opts?.forkSession ?? (async (sessionId: string) => ({ type: 'success', sessionId })) const engine = { resolveSessionAccess: () => ({ ok: true, sessionId: session.id, session }), applySessionConfig, listCodexModelsForSession, listOpencodeModelsForSession, - resumeSession + resumeSession, + forkSession } as Partial const app = new Hono() @@ -91,6 +94,26 @@ function createApp(session: Session, opts?: { } describe('sessions routes', () => { + it('returns conflict when fork is unavailable', async () => { + const { app } = createApp(createSession(), { + forkSession: async () => ({ + type: 'error', + message: 'Fork is only supported for Codex sessions', + code: 'fork_unavailable' + }) + }) + + const response = await app.request('/api/sessions/session-1/fork', { + method: 'POST' + }) + + expect(response.status).toBe(409) + expect(await response.json()).toEqual({ + error: 'Fork is only supported for Codex sessions', + code: 'fork_unavailable' + }) + }) + it('rejects collaboration mode changes for local Codex sessions', async () => { const session = createSession({ agentState: { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 96148e38a7..9dcc8a0e14 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -141,6 +141,49 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ type: 'success', sessionId: result.sessionId }) }) + app.post('/sessions/:id/fork', async (c) => { + const engine = requireSyncEngine(c, getSyncEngine) + if (engine instanceof Response) { + return engine + } + + const sessionResult = requireSessionFromParam(c, engine) + if (sessionResult instanceof Response) { + return sessionResult + } + + let beforeSeq: number | undefined + const contentType = c.req.header('content-type') ?? '' + if (contentType.includes('application/json')) { + const body = await c.req.json().catch(() => null) as { beforeSeq?: unknown } | null + if (body && body.beforeSeq !== undefined) { + if (typeof body.beforeSeq !== 'number' || !Number.isInteger(body.beforeSeq) || body.beforeSeq <= 0) { + return c.json({ error: 'beforeSeq must be a positive integer', code: 'fork_unavailable' }, 400) + } + beforeSeq = body.beforeSeq + } + } + + const namespace = c.get('namespace') + const result = await engine.forkSession( + sessionResult.sessionId, + namespace, + beforeSeq !== undefined ? { beforeSeq } : undefined + ) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : result.code === 'fork_unavailable' ? 409 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json(result.warnings && result.warnings.length > 0 + ? { type: 'success', sessionId: result.sessionId, warnings: result.warnings } + : { type: 'success', sessionId: result.sessionId }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/shared/src/schemas.ts b/shared/src/schemas.ts index 9a4ca0118c..23fb460f91 100644 --- a/shared/src/schemas.ts +++ b/shared/src/schemas.ts @@ -222,6 +222,10 @@ export const SyncEventSchema = z.discriminatedUnion('type', [ type: z.literal('session-ended'), reason: z.enum(['completed', 'terminated', 'error']).optional() }), + SessionChangedSchema.extend({ + type: z.literal('session-forked'), + sourceSessionId: z.string() + }), MachineChangedSchema.extend({ type: z.literal('machine-updated'), data: z.unknown().optional() diff --git a/shared/src/socket.ts b/shared/src/socket.ts index cdbe199262..735981066e 100644 --- a/shared/src/socket.ts +++ b/shared/src/socket.ts @@ -135,6 +135,15 @@ export interface ServerToClientEvents { export interface ClientToServerEvents { message: (data: { sid: string; message: unknown; localId?: string }) => void + 'codex-history-item': (data: { + sid: string + codexThreadId: string + turnId?: string | null + itemId: string + itemKind: 'user' | 'assistant' | 'tool' | 'event' | 'unknown' + messageSeq?: number | null + rawItem: unknown + }) => void 'session-alive': (data: { sid: string time: number diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2b12e81ff0..1372c1d984 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -296,6 +296,19 @@ export class ApiClient { return response.sessionId } + async forkSession(sessionId: string, opts?: { beforeSeq?: number }): Promise<{ sessionId: string; warnings?: string[] }> { + const response = await this.request<{ sessionId: string; warnings?: string[] }>( + `/api/sessions/${encodeURIComponent(sessionId)}/fork`, + { + method: 'POST', + ...(opts?.beforeSeq !== undefined && { + body: JSON.stringify({ beforeSeq: opts.beforeSeq }) + }) + } + ) + return { sessionId: response.sessionId, warnings: response.warnings } + } + async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise { await this.request(`/api/sessions/${encodeURIComponent(sessionId)}/messages`, { method: 'POST', diff --git a/web/src/chat/normalize.ts b/web/src/chat/normalize.ts index 7b6b73fb5e..d57371e437 100644 --- a/web/src/chat/normalize.ts +++ b/web/src/chat/normalize.ts @@ -10,6 +10,7 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized if (!record) { return { id: message.id, + seq: message.seq, localId: message.localId, createdAt: message.createdAt, role: 'agent', @@ -23,9 +24,10 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized if (record.role === 'user') { const normalized = normalizeUserRecord(message.id, message.localId, message.createdAt, record.content, record.meta) return normalized - ? { ...normalized, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } + ? { ...normalized, seq: message.seq, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } : { id: message.id, + seq: message.seq, localId: message.localId, createdAt: message.createdAt, role: 'user', @@ -46,9 +48,10 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized return null } return normalized - ? { ...normalized, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } + ? { ...normalized, seq: message.seq, status: message.status, originalText: message.originalText, invokedAt: message.invokedAt } : { id: message.id, + seq: message.seq, localId: message.localId, createdAt: message.createdAt, role: 'agent', @@ -63,6 +66,7 @@ export function normalizeDecryptedMessage(message: DecryptedMessage): Normalized return { id: message.id, + seq: message.seq, localId: message.localId, createdAt: message.createdAt, role: 'agent', diff --git a/web/src/chat/normalizeUser.ts b/web/src/chat/normalizeUser.ts index a9733e89e9..02faff473a 100644 --- a/web/src/chat/normalizeUser.ts +++ b/web/src/chat/normalizeUser.ts @@ -37,6 +37,7 @@ export function normalizeUserRecord( if (typeof content === 'string') { return { id: messageId, + seq: null, localId, createdAt, role: 'user', @@ -50,6 +51,7 @@ export function normalizeUserRecord( const attachments = parseAttachments(content.attachments) return { id: messageId, + seq: null, localId, createdAt, role: 'user', diff --git a/web/src/chat/reconcile.test.ts b/web/src/chat/reconcile.test.ts new file mode 100644 index 0000000000..063410771f --- /dev/null +++ b/web/src/chat/reconcile.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from 'vitest' + +import { reconcileChatBlocks } from './reconcile' +import type { ChatBlock } from './types' + +describe('reconcileChatBlocks', () => { + it('replaces agent text blocks when seq changes', () => { + const previous: ChatBlock = { + kind: 'agent-text', + id: 'assistant:1', + seq: null, + localId: null, + createdAt: 1, + text: 'answer' + } + const prevById = new Map([[previous.id, previous]]) + const next: ChatBlock = { + ...previous, + seq: 7 + } + + const result = reconcileChatBlocks([next], prevById) + + expect(result.blocks[0]).toBe(next) + expect(result.blocks[0]).not.toBe(previous) + }) + + it('replaces agent reasoning blocks when seq changes', () => { + const previous: ChatBlock = { + kind: 'agent-reasoning', + id: 'assistant:reasoning:1', + seq: null, + localId: null, + createdAt: 1, + text: 'thinking' + } + const prevById = new Map([[previous.id, previous]]) + const next: ChatBlock = { + ...previous, + seq: 7 + } + + const result = reconcileChatBlocks([next], prevById) + + expect(result.blocks[0]).toBe(next) + expect(result.blocks[0]).not.toBe(previous) + }) +}) diff --git a/web/src/chat/reconcile.ts b/web/src/chat/reconcile.ts index 12517a2e4c..693bdbb6f1 100644 --- a/web/src/chat/reconcile.ts +++ b/web/src/chat/reconcile.ts @@ -108,6 +108,7 @@ function areUserTextBlocksEqual(left: UserTextBlock, right: UserTextBlock): bool return left.text === right.text && left.status === right.status && left.originalText === right.originalText + && left.seq === right.seq && left.localId === right.localId && left.createdAt === right.createdAt && left.meta === right.meta @@ -115,6 +116,7 @@ function areUserTextBlocksEqual(left: UserTextBlock, right: UserTextBlock): bool function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): boolean { return left.text === right.text + && left.seq === right.seq && left.localId === right.localId && left.createdAt === right.createdAt && left.meta === right.meta @@ -122,6 +124,7 @@ function areAgentTextBlocksEqual(left: AgentTextBlock, right: AgentTextBlock): b function areAgentReasoningBlocksEqual(left: AgentReasoningBlock, right: AgentReasoningBlock): boolean { return left.text === right.text + && left.seq === right.seq && left.localId === right.localId && left.createdAt === right.createdAt && left.meta === right.meta diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index fcd432e7f2..aaca1d3dc3 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -111,6 +111,7 @@ export function reduceTimeline( blocks.push({ kind: 'user-text', id: msg.id, + seq: msg.seq, localId: msg.localId, createdAt: msg.createdAt, invokedAt: msg.invokedAt, @@ -182,6 +183,7 @@ export function reduceTimeline( blocks.push({ kind: 'agent-text', id: `${msg.id}:${idx}`, + seq: msg.seq, localId: msg.localId, createdAt: msg.createdAt, invokedAt: msg.invokedAt, @@ -197,6 +199,7 @@ export function reduceTimeline( blocks.push({ kind: 'agent-reasoning', id: `${msg.id}:${idx}`, + seq: msg.seq, localId: msg.localId, createdAt: msg.createdAt, invokedAt: msg.invokedAt, diff --git a/web/src/chat/types.ts b/web/src/chat/types.ts index 163d48079f..ea96f844c4 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -80,6 +80,7 @@ export type NormalizedMessage = ({ content: AgentEvent }) & { id: string + seq?: number | null localId: string | null createdAt: number isSidechain: boolean @@ -120,6 +121,7 @@ export type ChatToolCall = { export type UserTextBlock = { kind: 'user-text' id: string + seq?: number | null localId: string | null createdAt: number invokedAt?: number | null @@ -133,6 +135,7 @@ export type UserTextBlock = { export type AgentTextBlock = { kind: 'agent-text' id: string + seq?: number | null localId: string | null createdAt: number invokedAt?: number | null @@ -146,6 +149,7 @@ export type AgentTextBlock = { export type AgentReasoningBlock = { kind: 'agent-reasoning' id: string + seq?: number | null localId: string | null createdAt: number invokedAt?: number | null diff --git a/web/src/components/AssistantChat/HappyThread.tsx b/web/src/components/AssistantChat/HappyThread.tsx index 6016fd4392..6314062e30 100644 --- a/web/src/components/AssistantChat/HappyThread.tsx +++ b/web/src/components/AssistantChat/HappyThread.tsx @@ -156,6 +156,7 @@ export function HappyThread(props: { disabled: boolean onRefresh: () => void onRetryMessage?: (localId: string) => void + onForkBeforeMessage?: (seq: number) => void onFlushPending: () => void onAtBottomChange: (atBottom: boolean) => void isLoadingMessages: boolean @@ -384,7 +385,8 @@ export function HappyThread(props: { metadata: props.metadata, disabled: props.disabled, onRefresh: props.onRefresh, - onRetryMessage: props.onRetryMessage + onRetryMessage: props.onRetryMessage, + onForkBeforeMessage: props.onForkBeforeMessage }}> diff --git a/web/src/components/AssistantChat/context.tsx b/web/src/components/AssistantChat/context.tsx index e6d2b78cf2..5b596c726f 100644 --- a/web/src/components/AssistantChat/context.tsx +++ b/web/src/components/AssistantChat/context.tsx @@ -10,6 +10,7 @@ export type HappyChatContextValue = { disabled: boolean onRefresh: () => void onRetryMessage?: (localId: string) => void + onForkBeforeMessage?: (seq: number) => void } const HappyChatContext = createContext(null) diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx new file mode 100644 index 0000000000..9dc8891a56 --- /dev/null +++ b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx @@ -0,0 +1,103 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { HappyAssistantMessage } from '@/components/AssistantChat/messages/AssistantMessage' + +const state = vi.hoisted(() => ({ + message: { + role: 'assistant', + id: 'assistant:m1', + content: [{ type: 'text', text: 'answer' }], + metadata: { custom: { kind: 'assistant', seq: 8 } } + } as any +})) + +vi.mock('@assistant-ui/react', () => ({ + MessagePrimitive: { + Root: ({ children, ...props }: { children: ReactNode }) =>
{children}
, + Content: () =>
answer
+ }, + useAssistantState: (selector: (snapshot: { message: unknown }) => unknown) => selector({ message: state.message }) +})) + +vi.mock('@/components/assistant-ui/markdown-text', () => ({ + MarkdownText: () => null +})) + +vi.mock('@/components/assistant-ui/reasoning', () => ({ + Reasoning: () => null, + ReasoningGroup: () => null +})) + +vi.mock('@/components/AssistantChat/messages/ToolMessage', () => ({ + HappyToolMessage: () => null +})) + +function renderAssistantMessage(onForkBeforeMessage?: (seq: number) => void) { + return render( + + + + ) +} + +describe('HappyAssistantMessage fork action', () => { + afterEach(() => { + cleanup() + }) + + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }) + state.message = { + role: 'assistant', + id: 'assistant:m1', + content: [{ type: 'text', text: 'answer' }], + metadata: { custom: { kind: 'assistant', seq: 8 } } + } as any + }) + + it('shows fork action for assistant messages with a seq', () => { + const onForkBeforeMessage = vi.fn() + renderAssistantMessage(onForkBeforeMessage) + + fireEvent.click(screen.getByTitle('Fork from this response')) + + expect(onForkBeforeMessage).toHaveBeenCalledWith(8) + }) + + it('does not show fork action without a seq', () => { + state.message = { + role: 'assistant', + id: 'assistant:m1', + content: [{ type: 'text', text: 'answer' }], + metadata: { custom: { kind: 'assistant' } } + } + + renderAssistantMessage(vi.fn()) + + expect(screen.queryByTitle('Fork from this response')).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index b70105df29..0db7fbdbeb 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -3,8 +3,9 @@ import { MessagePrimitive, useAssistantState } from '@assistant-ui/react' import { MarkdownText } from '@/components/assistant-ui/markdown-text' import { Reasoning, ReasoningGroup } from '@/components/assistant-ui/reasoning' import { HappyToolMessage } from '@/components/AssistantChat/messages/ToolMessage' +import { useHappyChatContext } from '@/components/AssistantChat/context' import { CliOutputBlock } from '@/components/CliOutputBlock' -import { CopyIcon, CheckIcon } from '@/components/icons' +import { CopyIcon, CheckIcon, ForkIcon } from '@/components/icons' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { getAssistantCopyText } from '@/components/AssistantChat/messages/assistantCopyText' @@ -24,6 +25,7 @@ const MESSAGE_PART_COMPONENTS = { } as const export function HappyAssistantMessage() { + const ctx = useHappyChatContext() const { copied, copy } = useCopyToClipboard() const [showMetadata, setShowMetadata] = useState(false) const toggleMetadata = useCallback((event: MouseEvent) => { @@ -49,16 +51,21 @@ export function HappyAssistantMessage() { if (message.role !== 'assistant') return '' return getAssistantCopyText(message.content) }) - const invokedAt = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.invokedAt) const durationMs = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.durationMs) const usage = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.usage) const messageModel = useAssistantState(({ message }) => (message.metadata.custom as Partial | undefined)?.model) + const seq = useAssistantState(({ message }) => { + if (message.role !== 'assistant') return null + const custom = message.metadata.custom as Partial | undefined + return custom?.kind === 'assistant' && typeof custom.seq === 'number' ? custom.seq : null + }) const hasMetadata = invokedAt != null || (typeof durationMs === 'number' && durationMs >= 0) || usage != null || (messageModel != null && messageModel !== '') + const canFork = typeof seq === 'number' && Boolean(ctx.onForkBeforeMessage) const onMetadataKeyDown = useCallback((event: KeyboardEvent) => { if (isNestedInteractiveEvent(event)) return @@ -105,7 +112,7 @@ export function HappyAssistantMessage() { return (
)} - {copyText && ( -
- + {(copyText || canFork) && ( +
+ {copyText && ( + + )} + {canFork && ( + + )}
)} diff --git a/web/src/components/AssistantChat/messages/UserMessage.test.tsx b/web/src/components/AssistantChat/messages/UserMessage.test.tsx new file mode 100644 index 0000000000..b6f590afe3 --- /dev/null +++ b/web/src/components/AssistantChat/messages/UserMessage.test.tsx @@ -0,0 +1,92 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { cleanup, render, screen } from '@testing-library/react' +import type { ReactNode } from 'react' + +import { HappyChatProvider } from '@/components/AssistantChat/context' +import { HappyUserMessage } from '@/components/AssistantChat/messages/UserMessage' + +const state = vi.hoisted(() => ({ + message: { + role: 'user', + id: 'user:m1', + content: [{ type: 'text', text: 'hello' }], + metadata: { custom: { kind: 'user', seq: 7, localId: null } } + } as any +})) + +vi.mock('@assistant-ui/react', () => ({ + MessagePrimitive: { + Root: ({ children, ...props }: { children: ReactNode }) =>
{children}
+ }, + useAssistantState: (selector: (snapshot: { message: unknown }) => unknown) => selector({ message: state.message }) +})) + +vi.mock('@/components/LazyRainbowText', () => ({ + LazyRainbowText: ({ text }: { text: string }) => {text} +})) + +function renderUserMessage(onForkBeforeMessage?: (seq: number) => void) { + return render( + + + + ) +} + +describe('HappyUserMessage fork action', () => { + afterEach(() => { + cleanup() + }) + + beforeEach(() => { + Object.defineProperty(window, 'matchMedia', { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn() + })) + }) + state.message = { + role: 'user', + id: 'user:m1', + content: [{ type: 'text', text: 'hello' }], + metadata: { custom: { kind: 'user', seq: 7, localId: null } } + } as any + }) + + it('shows fork-before action for user messages with a seq', () => { + const onForkBeforeMessage = vi.fn() + renderUserMessage(onForkBeforeMessage) + + screen.getByTitle('Fork before here').click() + expect(onForkBeforeMessage).toHaveBeenCalledWith(7) + }) + + it('does not show fork action for non-user messages', () => { + state.message = { + role: 'assistant', + id: 'assistant:m1', + content: [{ type: 'text', text: 'answer' }], + metadata: { custom: { kind: 'assistant' } } + } + + renderUserMessage(vi.fn()) + + expect(screen.queryByTitle('Fork before here')).toBeNull() + }) +}) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index e55ac9af30..b54c1b0559 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -6,7 +6,7 @@ import type { HappyChatMessageMetadata } from '@/lib/assistant-runtime' import { MessageStatusIndicator } from '@/components/AssistantChat/messages/MessageStatusIndicator' import { MessageAttachments } from '@/components/AssistantChat/messages/MessageAttachments' import { CliOutputBlock } from '@/components/CliOutputBlock' -import { CopyIcon, CheckIcon } from '@/components/icons' +import { CopyIcon, CheckIcon, ForkIcon } from '@/components/icons' import { useCopyToClipboard } from '@/hooks/useCopyToClipboard' import { getConversationMessageAnchorId } from '@/chat/outline' import { MessageMetadata } from '@/components/AssistantChat/messages/MessageMetadata' @@ -36,6 +36,11 @@ export function HappyUserMessage() { const custom = message.metadata.custom as Partial | undefined return custom?.localId ?? null }) + const seq = useAssistantState(({ message }) => { + if (message.role !== 'user') return null + const custom = message.metadata.custom as Partial | undefined + return typeof custom?.seq === 'number' ? custom.seq : null + }) const attachments = useAssistantState(({ message }) => { if (message.role !== 'user') return undefined const custom = message.metadata.custom as Partial | undefined @@ -66,6 +71,7 @@ export function HappyUserMessage() { if (role !== 'user') return null const canRetry = status === 'failed' && typeof localId === 'string' && Boolean(ctx.onRetryMessage) const onRetry = canRetry ? () => ctx.onRetryMessage!(localId) : undefined + const canForkBefore = typeof seq === 'number' && Boolean(ctx.onForkBeforeMessage) const userBubbleClass = `w-fit min-w-0 max-w-[92%] ml-auto rounded-xl bg-[var(--app-secondary-bg)] px-3 py-2 text-[var(--app-fg)] shadow-sm` @@ -116,7 +122,7 @@ export function HappyUserMessage() { {hasText && } {hasAttachments && }
- {(hasText || status) && ( + {(hasText || status || canForkBefore) && (
{hasText && ( )} + {canForkBefore && ( + + )} {status && }
)} diff --git a/web/src/components/SessionActionMenu.tsx b/web/src/components/SessionActionMenu.tsx index 88d6ab97c9..1077c94b15 100644 --- a/web/src/components/SessionActionMenu.tsx +++ b/web/src/components/SessionActionMenu.tsx @@ -13,7 +13,9 @@ type SessionActionMenuProps = { isOpen: boolean onClose: () => void sessionActive: boolean + canFork?: boolean onRename: () => void + onFork?: () => void onArchive: () => void onDelete: () => void anchorPoint: { x: number; y: number } @@ -61,6 +63,29 @@ function ArchiveIcon(props: { className?: string }) { ) } +function ForkIcon(props: { className?: string }) { + return ( + + + + + + + + ) +} + function TrashIcon(props: { className?: string }) { return ( { + onClose() + onFork?.() + } + const handleDelete = () => { onClose() onDelete() @@ -239,6 +271,18 @@ export function SessionActionMenu(props: SessionActionMenuProps) { {t('session.action.rename')} + {canFork ? ( + + ) : null} + {sessionActive ? (
{toasts.map((toast) => ( @@ -20,6 +20,8 @@ export function ToastContainer() { key={toast.id} title={toast.title} body={toast.body} + variant={toast.variant} + actionLabel={toast.actionLabel} className="cursor-pointer" onClick={() => { removeToast(toast.id) diff --git a/web/src/components/icons.tsx b/web/src/components/icons.tsx index 5200e05fa6..d776372dc6 100644 --- a/web/src/components/icons.tsx +++ b/web/src/components/icons.tsx @@ -60,3 +60,17 @@ export function CheckIcon(props: IconProps) { 2 ) } + +export function ForkIcon(props: IconProps) { + return createIcon( + <> + + + + + + , + props, + 2 + ) +} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx index 31263cc4ae..ebf650831b 100644 --- a/web/src/components/ui/Toast.tsx +++ b/web/src/components/ui/Toast.tsx @@ -3,11 +3,29 @@ import { cva, type VariantProps } from 'class-variance-authority' import { cn } from '@/lib/utils' const toastVariants = cva( - 'pointer-events-auto w-full max-w-sm rounded-lg border border-[var(--app-border)] bg-[var(--app-bg)] text-[var(--app-fg)] shadow-lg', + 'pointer-events-auto w-full max-w-sm rounded-xl border text-[var(--app-fg)] shadow-lg transition-all', { variants: { variant: { - default: 'border-[var(--app-border)] bg-[var(--app-bg)]' + default: 'border-[var(--app-border)] bg-[var(--app-bg)]', + success: 'border-emerald-500/35 bg-emerald-50 text-emerald-950 shadow-emerald-500/10 ring-1 ring-emerald-500/15 dark:bg-emerald-950 dark:text-emerald-50', + error: 'border-red-500/35 bg-red-50 text-red-950 shadow-red-500/10 ring-1 ring-red-500/15 dark:bg-red-950 dark:text-red-50' + } + }, + defaultVariants: { + variant: 'default' + } + } +) + +const toastActionVariants = cva( + 'mt-2 inline-flex w-fit items-center gap-1 rounded-full px-2.5 py-1 text-[11px] font-medium ring-1', + { + variants: { + variant: { + default: 'bg-[var(--app-secondary-bg)] text-[var(--app-fg)] ring-[var(--app-border)]', + success: 'bg-white/90 text-emerald-700 ring-emerald-500/20 dark:bg-emerald-900/80 dark:text-emerald-200', + error: 'bg-white/90 text-red-700 ring-red-500/20 dark:bg-red-900/80 dark:text-red-200' } }, defaultVariants: { @@ -20,10 +38,31 @@ export type ToastProps = React.HTMLAttributes & VariantProps & { title: string body: string + actionLabel?: string onClose?: () => void } -export function Toast({ title, body, onClose, className, variant, ...props }: ToastProps) { +function ArrowRightIcon(props: { className?: string }) { + return ( + + + + + ) +} + +export function Toast({ title, body, actionLabel, onClose, className, variant, ...props }: ToastProps) { const handleClose = (event: React.MouseEvent) => { event.stopPropagation() onClose?.() @@ -31,15 +70,21 @@ export function Toast({ title, body, onClose, className, variant, ...props }: To return (
-
-
+
+
{title}
-
{body}
+
{body}
+ {actionLabel ? ( +
+ {actionLabel} + +
+ ) : null}
{onClose ? (