From abf20494028885260eba0b2761a62a4c75b97931 Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 13 Apr 2026 12:27:05 +0800 Subject: [PATCH 01/27] feat: support codex fork across cli hub and web --- README.md | 1 + cli/README.md | 1 + cli/src/api/apiMachine.ts | 3 +- cli/src/codex/appServerTypes.ts | 23 ++++++ cli/src/codex/codexAppServerClient.ts | 10 +++ cli/src/codex/codexLocal.test.ts | 38 ++++++--- cli/src/codex/codexLocal.ts | 27 ++++--- cli/src/codex/codexLocalLauncher.ts | 4 +- cli/src/codex/codexRemoteLauncher.test.ts | 67 +++++++++++++--- cli/src/codex/codexRemoteLauncher.ts | 64 ++++++++++++++- cli/src/codex/loop.ts | 2 + cli/src/codex/runCodex.ts | 2 + cli/src/codex/session.ts | 3 + cli/src/codex/utils/appServerConfig.test.ts | 29 ++++++- cli/src/codex/utils/appServerConfig.ts | 32 ++++++++ cli/src/commands/codex.ts | 10 +++ cli/src/modules/common/rpcTypes.ts | 1 + cli/src/runner/run.ts | 4 +- docs/plans/hapi-feature-codex-fork/design.md | 73 +++++++++++++++++ .../deploy_modules.md | 3 + .../design.md | 16 ++++ hub/src/sync/rpcGateway.ts | 16 +++- hub/src/sync/sessionModel.test.ts | 66 ++++++++++++++- hub/src/sync/syncEngine.ts | 80 +++++++++++++++++++ hub/src/web/routes/machines.ts | 1 + hub/src/web/routes/sessions.ts | 24 ++++++ web/src/api/client.ts | 8 ++ web/src/components/SessionActionMenu.tsx | 44 ++++++++++ web/src/components/SessionHeader.tsx | 35 +++++++- web/src/components/SessionList.tsx | 31 ++++++- web/src/components/ToastContainer.tsx | 2 + web/src/components/ui/Toast.tsx | 34 +++++++- web/src/hooks/mutations/useSessionActions.ts | 13 +++ web/src/lib/locales/en.ts | 5 ++ web/src/lib/locales/zh-CN.ts | 5 ++ web/src/lib/toast-context.tsx | 2 + 36 files changed, 732 insertions(+), 47 deletions(-) create mode 100644 docs/plans/hapi-feature-codex-fork/design.md create mode 100644 docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/deploy_modules.md create mode 100644 docs/plans/hapi-feature-codex-fork/proposals/2026-04-02-12-24-codex-fork-support/design.md 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/api/apiMachine.ts b/cli/src/api/apiMachine.ts index 99a7d3344c..a52c23f1d2 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, machineId, approvedNewDirectoryCreation, agent, model, effort, modelReasoningEffort, yolo, permissionMode, token, sessionType, worktreeName } = params || {} if (!directory) { throw new Error('Directory is required') @@ -291,6 +291,7 @@ export class ApiMachineClient { directory, sessionId, resumeSessionId, + forkSessionId, machineId, approvedNewDirectoryCreation, agent, 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..f3385ca8cc 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -12,7 +12,27 @@ const harness = vi.hoisted(() => ({ interruptedTurns: [] as Array<{ threadId: string; turnId: string }>, compactThreadIds: [] as string[], suppressTurnCompletion: false, - remainingThreadSystemErrors: 0 + remainingThreadSystemErrors: 0, + forkCalls: [] as unknown[] +})); + +vi.mock('react', () => ({ + default: { + createElement: () => ({}) + } +})); + +vi.mock('ink', () => ({ + render: () => ({ + unmount: () => {} + }) +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + debug: () => {}, + warn: () => {} + } })); vi.mock('./codexAppServerClient', () => { @@ -46,6 +66,11 @@ vi.mock('./codexAppServerClient', () => { 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,7 +169,8 @@ function createMode(): EnhancedMode { }; } -function createSessionStub(messages = ['hello from launcher test']) { +function createSessionStub(opts?: { messages?: string[]; forkSessionId?: string | null }) { + const messages = opts?.messages ?? ['hello from launcher test']; const queue = new MessageQueue2((mode) => JSON.stringify(mode)); messages.forEach((message, index) => { if (index === 0 && messages.length > 1) { @@ -193,10 +219,14 @@ function createSessionStub(messages = ['hello from launcher test']) { codexArgs: undefined, codexCliOverrides: undefined, sessionId: null as string | null, + forkSessionId: opts?.forkSessionId ?? undefined, thinking: false, getPermissionMode() { return 'default' as const; }, + getCollaborationMode() { + return 'default' as const; + }, setModel(nextModel: string | null) { currentModel = nextModel; }, @@ -251,6 +281,7 @@ describe('codexRemoteLauncher', () => { harness.compactThreadIds = []; harness.suppressTurnCompletion = false; harness.remainingThreadSystemErrors = 0; + harness.forkCalls = []; }); it('finishes a turn and emits ready when task lifecycle events include turn_id', async () => { @@ -305,7 +336,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 +372,26 @@ 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('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 +408,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 +425,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 +445,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 +462,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 +477,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..bd8df9f734 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -14,7 +14,7 @@ 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 { @@ -598,6 +598,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 +629,29 @@ class CodexRemoteLauncher extends RemoteLauncherBase { let hasThread = false; let pending: QueuedMessage | null = null; + if (session.forkSessionId) { + const forkResponse = await appServerClient.forkThread(buildThreadForkParams({ + threadId: session.forkSessionId, + cwd: session.path, + mode: buildInitialMode(), + mcpServers, + cliOverrides: session.codexCliOverrides + }), { + signal: this.abortController.signal + }); + const forkRecord = asRecord(forkResponse); + const forkThread = forkRecord ? asRecord(forkRecord.thread) : null; + const forkedThreadId = asString(forkThread?.id); + if (!forkedThreadId) { + throw new Error('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 +828,32 @@ 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) { + logger.warn(`[Codex] Failed to fork app-server thread ${forkCandidate}, starting new thread`, error); + } + } else if (resumeCandidate) { try { const resumeResponse = await appServerClient.resumeThread({ threadId: resumeCandidate, diff --git a/cli/src/codex/loop.ts b/cli/src/codex/loop.ts index 223807b1c7..afd86e5ec4 100644 --- a/cli/src/codex/loop.ts +++ b/cli/src/codex/loop.ts @@ -33,6 +33,7 @@ interface LoopOptions { modelReasoningEffort?: ReasoningEffort; collaborationMode?: CodexCollaborationMode; resumeSessionId?: string; + forkSessionId?: string; onSessionReady?: (session: CodexSession) => void; } @@ -53,6 +54,7 @@ export async function loop(opts: LoopOptions): Promise { startingMode, codexArgs: opts.codexArgs, codexCliOverrides: opts.codexCliOverrides, + forkSessionId: opts.forkSessionId, 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..2eaa20b696 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -27,6 +27,7 @@ export async function runCodex(opts: { codexArgs?: string[]; permissionMode?: PermissionMode; resumeSessionId?: string; + forkSessionId?: string; model?: string; modelReasoningEffort?: ReasoningEffort; }): Promise { @@ -312,6 +313,7 @@ export async function runCodex(opts: { modelReasoningEffort: currentModelReasoningEffort, collaborationMode: currentCollaborationMode, resumeSessionId: opts.resumeSessionId, + forkSessionId: opts.forkSessionId, onModeChange: createModeChangeHandler(session), onSessionReady: (instance) => { sessionWrapperRef.current = instance; diff --git a/cli/src/codex/session.ts b/cli/src/codex/session.ts index 701c69e9c0..8b12fd7ac6 100644 --- a/cli/src/codex/session.ts +++ b/cli/src/codex/session.ts @@ -15,6 +15,7 @@ export class CodexSession extends AgentSessionBase { transcriptPath: string | null = null; readonly codexArgs?: string[]; readonly codexCliOverrides?: CodexCliOverrides; + readonly forkSessionId?: string; readonly startedBy: 'runner' | 'terminal'; readonly startingMode: 'local' | 'remote'; localLaunchFailure: LocalLaunchFailure | null = null; @@ -34,6 +35,7 @@ export class CodexSession extends AgentSessionBase { startingMode: 'local' | 'remote'; codexArgs?: string[]; codexCliOverrides?: CodexCliOverrides; + forkSessionId?: string; permissionMode?: PermissionMode; model?: SessionModel; modelReasoningEffort?: SessionModelReasoningEffort; @@ -62,6 +64,7 @@ export class CodexSession extends AgentSessionBase { this.codexArgs = opts.codexArgs; this.codexCliOverrides = opts.codexCliOverrides; + this.forkSessionId = opts.forkSessionId; 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..9b391803ed 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -34,6 +34,7 @@ export const codexCommand: CommandDefinition = { codexArgs?: string[] permissionMode?: CodexPermissionMode resumeSessionId?: string + forkSessionId?: string model?: string modelReasoningEffort?: ReasoningEffort } = {} @@ -51,6 +52,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') { diff --git a/cli/src/modules/common/rpcTypes.ts b/cli/src/modules/common/rpcTypes.ts index 6336f57dd8..28b3c49857 100644 --- a/cli/src/modules/common/rpcTypes.ts +++ b/cli/src/modules/common/rpcTypes.ts @@ -3,6 +3,7 @@ export interface SpawnSessionOptions { directory: string sessionId?: string resumeSessionId?: string + forkSessionId?: string approvedNewDirectoryCreation?: boolean agent?: 'claude' | 'codex' | 'cursor' | 'gemini' | 'opencode' model?: string diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index c0f02a4d4a..b228cb009d 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -915,7 +915,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/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/sync/rpcGateway.ts b/hub/src/sync/rpcGateway.ts index 977b4ead4e..de61eb28c6 100644 --- a/hub/src/sync/rpcGateway.ts +++ b/hub/src/sync/rpcGateway.ts @@ -142,6 +142,7 @@ export class RpcGateway { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string, permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { @@ -149,7 +150,20 @@ export class RpcGateway { 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, + effort, + permissionMode + } ) if (result && typeof result === 'object') { const obj = result as Record diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index f743fe656a..c6f847749f 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,67 @@ 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' + ) + 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 + ;(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 + capturedForkSessionId = forkSessionId + return { type: 'success', sessionId: 'forked-session' } + } + ;(engine as any).waitForSessionActive = async () => true + + const result = await engine.forkSession(session.id, 'default') + + expect(result).toEqual({ type: 'success', sessionId: 'forked-session' }) + expect(capturedForkSessionId).toBe('codex-thread-1') + expect(capturedModel).toBe('gpt-5.4') + } finally { + engine.stop() + } + }) + describe('session dedup by agent session ID', () => { it('merges duplicate when codexSessionId collides', async () => { const store = new Store(':memory:') diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index c3c59bd33c..6ae14195fd 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -50,6 +50,10 @@ 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 } + | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'fork_unavailable' | 'fork_failed' } + export class SyncEngine { private readonly eventPublisher: EventPublisher private readonly sessionCache: SessionCache @@ -402,6 +406,7 @@ export class SyncEngine { sessionType?: 'simple' | 'worktree', worktreeName?: string, resumeSessionId?: string, + forkSessionId?: string, effort?: string, permissionMode?: PermissionMode ): Promise<{ type: 'success'; sessionId: string } | { type: 'error'; message: string }> { @@ -415,6 +420,7 @@ export class SyncEngine { sessionType, worktreeName, resumeSessionId, + forkSessionId, effort, permissionMode ) @@ -489,6 +495,7 @@ export class SyncEngine { undefined, undefined, resumeToken, + undefined, session.effort ?? undefined, effectivePermissionMode ) @@ -540,6 +547,79 @@ export class SyncEngine { } } + async forkSession(sessionId: string, namespace: string): 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' } + } + + const forkToken = metadata.codexSessionId + if (!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, + undefined, + undefined, + undefined, + undefined, + undefined, + forkToken, + session.effort ?? undefined, + session.permissionMode ?? undefined + ) + + if (spawnResult.type !== 'success') { + return { type: 'error', message: spawnResult.message, code: 'fork_failed' } + } + + const becameActive = await this.waitForSessionActive(spawnResult.sessionId) + if (!becameActive) { + return { type: 'error', message: 'Session failed to become active', code: 'fork_failed' } + } + + return { 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.ts b/hub/src/web/routes/sessions.ts index 96148e38a7..dd8986f34b 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -141,6 +141,30 @@ 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 + } + + const namespace = c.get('namespace') + const result = await engine.forkSession(sessionResult.sessionId, namespace) + if (result.type === 'error') { + const status = result.code === 'no_machine_online' ? 503 + : result.code === 'access_denied' ? 403 + : result.code === 'session_not_found' ? 404 + : 500 + return c.json({ error: result.message, code: result.code }, status) + } + + return c.json({ type: 'success', sessionId: result.sessionId }) + }) + app.post('/sessions/:id/upload', async (c) => { const engine = requireSyncEngine(c, getSyncEngine) if (engine instanceof Response) { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 2b12e81ff0..503ed4b6c1 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -296,6 +296,14 @@ export class ApiClient { return response.sessionId } + async forkSession(sessionId: string): Promise { + const response = await this.request<{ sessionId: string }>( + `/api/sessions/${encodeURIComponent(sessionId)}/fork`, + { method: 'POST' } + ) + return response.sessionId + } + 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/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 ? (
{title}
{body}
+ {actionLabel ? ( +
+ {actionLabel} + +
+ ) : null}
{onClose ? ( )} + {canForkBefore && ( + + )} {status && } )} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index a6fe892bee..9cac9812e6 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -32,6 +32,7 @@ import { useOpencodeModels } from '@/hooks/queries/useOpencodeModels' import { useVoiceOptional } from '@/lib/voice-context' import { RealtimeVoiceSession, registerSessionStore, registerVoiceHooksStore, voiceHooks } from '@/realtime' import { isRemoteTerminalSupported } from '@/utils/terminalSupport' +import { useToast } from '@/lib/toast-context' function getOutlineTitle(session: Session): string { if (session.metadata?.name) { @@ -69,6 +70,7 @@ export function SessionChat(props: { }) { const { haptic } = usePlatform() const { t } = useTranslation() + const { addToast } = useToast() const navigate = useNavigate() const sessionInactive = !props.session.active const terminalSupported = isRemoteTerminalSupported(props.session.metadata) @@ -377,6 +379,34 @@ export function SessionChat(props: { setForceScrollToken((token) => token + 1) }, [props.onSend]) + const handleForkBeforeMessage = useCallback(async (beforeSeq: number) => { + try { + const newSessionId = await props.api.forkSession(props.session.id, { beforeSeq }) + haptic.notification('success') + addToast({ + title: t('dialog.fork.successTitle'), + body: t('dialog.fork.successDescription', { name: getOutlineTitle(props.session) }), + sessionId: newSessionId, + url: `/sessions/${newSessionId}`, + variant: 'success', + actionLabel: t('toast.action.openSession') + }) + navigate({ + to: '/sessions/$sessionId', + params: { sessionId: newSessionId } + }) + } catch (error) { + haptic.notification('error') + const message = error instanceof Error ? error.message : t('dialog.fork.failedDescription') + addToast({ + title: t('dialog.fork.failedTitle'), + body: message, + sessionId: props.session.id, + url: `/sessions/${props.session.id}` + }) + } + }, [addToast, haptic, navigate, props.api, props.session, t]) + const attachmentAdapter = useMemo(() => { if (!props.session.active) { return undefined @@ -427,6 +457,7 @@ export function SessionChat(props: { disabled={sessionInactive} onRefresh={props.onRefresh} onRetryMessage={props.onRetryMessage} + onForkBeforeMessage={handleForkBeforeMessage} onFlushPending={props.onFlushPending} onAtBottomChange={props.onAtBottomChange} isLoadingMessages={props.isLoadingMessages} 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/lib/assistant-runtime.ts b/web/src/lib/assistant-runtime.ts index bcd0a33838..31b7d321d6 100644 --- a/web/src/lib/assistant-runtime.ts +++ b/web/src/lib/assistant-runtime.ts @@ -10,6 +10,7 @@ import type { AttachmentMetadata, MessageStatus as HappyMessageStatus, Session } export type HappyChatMessageMetadata = { kind: 'user' | 'assistant' | 'tool' | 'event' | 'cli-output' status?: HappyMessageStatus + seq?: number | null localId?: string | null originalText?: string toolCallId?: string @@ -34,6 +35,7 @@ function toThreadMessageLike(block: ChatBlock): ThreadMessageLike { custom: { kind: 'user', status: block.status, + seq: block.seq, localId: block.localId, originalText: block.originalText, attachments: block.attachments, From 1969755a5d002a4bc02fc9cb86bc9429f438919c Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 16:27:22 +0800 Subject: [PATCH 08/27] feat: fork from assistant responses --- hub/src/store/codexHistoryStore.test.ts | 48 +++++++++ hub/src/store/codexHistoryStore.ts | 40 ++++++++ hub/src/store/messageStore.ts | 10 +- hub/src/store/messages.ts | 39 ++++++++ hub/src/sync/sessionModel.test.ts | 14 ++- hub/src/sync/syncEngine.ts | 16 ++- web/src/chat/reducerTimeline.ts | 2 + web/src/chat/types.ts | 2 + .../messages/AssistantMessage.test.tsx | 99 +++++++++++++++++++ .../messages/AssistantMessage.tsx | 47 ++++++--- .../messages/UserMessage.test.tsx | 13 ++- .../AssistantChat/messages/UserMessage.tsx | 2 +- web/src/lib/assistant-runtime.ts | 2 + 13 files changed, 304 insertions(+), 30 deletions(-) create mode 100644 web/src/components/AssistantChat/messages/AssistantMessage.test.tsx diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts index 710be0060a..1f1e27ed32 100644 --- a/hub/src/store/codexHistoryStore.test.ts +++ b/hub/src/store/codexHistoryStore.test.ts @@ -56,6 +56,54 @@ describe('CodexHistoryStore', () => { 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('deletes codex history rows when deleting the session', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index acd9fe4b27..68ba5a1f81 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -48,6 +48,46 @@ export class CodexHistoryStore { }) } + 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 raw_item + FROM codex_history_items + WHERE session_id = @sessionId + ${beforeClause} + ORDER BY seq ASC + `).all({ + sessionId, + nextUserSeq: nextUser?.seq ?? null + }) as Array<{ raw_item: string }> + + return rows.map((row) => safeJsonParse(row.raw_item)) + } + getPrefixBeforeMessageSeq(sessionId: string, beforeSeq: number): unknown[] | null { const cut = this.db.prepare(` SELECT seq diff --git a/hub/src/store/messageStore.ts b/hub/src/store/messageStore.ts index 6a6f361ecd..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, cloneSessionMessages, getMessageBySeq, 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 @@ -34,6 +34,14 @@ export class MessageStore { 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) } diff --git a/hub/src/store/messages.ts b/hub/src/store/messages.ts index f38aeff9f8..dd41fdfe6f 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( diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 56ed175171..effbb2daa6 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -748,7 +748,7 @@ describe('session model', () => { } }) - it('passes raw history instead of latest fork token for historical codex fork', async () => { + it('passes raw history through the selected user reply for historical codex fork', async () => { const store = new Store(':memory:') const engine = new SyncEngine( store, @@ -782,6 +782,7 @@ describe('session model', () => { 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', @@ -805,6 +806,13 @@ describe('session model', () => { 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 @@ -843,7 +851,7 @@ describe('session model', () => { } ;(engine as any).waitForSessionActive = async () => true - const result = await engine.forkSession(session.id, 'default', { beforeSeq: 3 }) + const result = await engine.forkSession(session.id, 'default', { beforeSeq: 2 }) expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) expect(capturedForkSessionId).toBeUndefined() @@ -888,7 +896,7 @@ describe('session model', () => { expect(await engine.forkSession(session.id, 'default', { beforeSeq: 2 })).toMatchObject({ type: 'error', code: 'fork_unavailable', - message: 'Historical fork cut point must be a user message' + message: '历史点 fork 只支持新版本会话' }) expect(await engine.forkSession(session.id, 'default', { beforeSeq: 1 })).toMatchObject({ type: 'error', diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 2bdcb740c9..2d1f0c1bfb 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -585,10 +585,18 @@ export class SyncEngine { } const cutMessage = this.store.messages.getMessageBySeq(sessionId, opts.beforeSeq) const record = cutMessage ? unwrapRoleWrappedRecordEnvelope(cutMessage.content) : null - if (!cutMessage || record?.role !== 'user') { - return { type: 'error', message: 'Historical fork cut point must be a user message', code: 'fork_unavailable' } + const userMessageSeq = (() => { + if (!cutMessage || !record) return null + if (record.role === 'user') return 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: 'Historical fork cut point must be an agent response', code: 'fork_unavailable' } } - const prefix = this.store.codexHistory.getPrefixBeforeMessageSeq(sessionId, opts.beforeSeq) + const prefix = this.store.codexHistory.getPrefixThroughReplyForUserMessageSeq(sessionId, userMessageSeq) if (!prefix) { return { type: 'error', @@ -597,7 +605,7 @@ export class SyncEngine { } } forkHistory = prefix - cloneBeforeSeq = opts.beforeSeq + cloneBeforeSeq = this.store.messages.getNextUserMessageSeq(sessionId, userMessageSeq) ?? undefined } const onlineMachines = this.machineCache.getOnlineMachinesByNamespace(namespace) diff --git a/web/src/chat/reducerTimeline.ts b/web/src/chat/reducerTimeline.ts index e2a89977b4..aaca1d3dc3 100644 --- a/web/src/chat/reducerTimeline.ts +++ b/web/src/chat/reducerTimeline.ts @@ -183,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, @@ -198,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 159da1420d..ea96f844c4 100644 --- a/web/src/chat/types.ts +++ b/web/src/chat/types.ts @@ -135,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 @@ -148,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/messages/AssistantMessage.test.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx new file mode 100644 index 0000000000..ec46804c78 --- /dev/null +++ b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx @@ -0,0 +1,99 @@ +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: ({ text }: { text: string }) => {text} +})) + +vi.mock('@/components/assistant-ui/reasoning', () => ({ + Reasoning: ({ text }: { text: string }) => {text}, + ReasoningGroup: ({ children }: { children: ReactNode }) =>
{children}
+})) + +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 responses 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 for tool-only assistant messages', () => { + state.message = { + role: 'assistant', + id: 'tool:m1', + content: [{ type: 'tool-call', toolCallId: 't1', toolName: 'Tool', argsText: '{}' }], + metadata: { custom: { kind: 'tool', toolCallId: 't1' } } + } as any + + 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..2bae49b565 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 index bef6e68d53..712b855a67 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.test.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.test.tsx @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { cleanup, render, screen } from '@testing-library/react' import type { ReactNode } from 'react' import { HappyChatProvider } from '@/components/AssistantChat/context' @@ -69,16 +69,15 @@ describe('HappyUserMessage fork action', () => { } as any }) - it('shows Fork before here for user messages with a seq', () => { + it('does not show fork action for user messages with a seq', () => { const onForkBeforeMessage = vi.fn() renderUserMessage(onForkBeforeMessage) - fireEvent.click(screen.getByTitle('Fork before here')) - - expect(onForkBeforeMessage).toHaveBeenCalledWith(7) + expect(screen.queryByTitle('Fork from this response')).toBeNull() + expect(onForkBeforeMessage).not.toHaveBeenCalled() }) - it('does not show Fork before here for non-user messages', () => { + it('does not show fork action for non-user messages', () => { state.message = { role: 'assistant', id: 'assistant:m1', @@ -88,6 +87,6 @@ describe('HappyUserMessage fork action', () => { renderUserMessage(vi.fn()) - expect(screen.queryByTitle('Fork before here')).toBeNull() + expect(screen.queryByTitle('Fork from this response')).toBeNull() }) }) diff --git a/web/src/components/AssistantChat/messages/UserMessage.tsx b/web/src/components/AssistantChat/messages/UserMessage.tsx index 2f811db0b7..b54c1b0559 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.tsx @@ -122,7 +122,7 @@ export function HappyUserMessage() { {hasText && } {hasAttachments && }
- {(hasText || status) && ( + {(hasText || status || canForkBefore) && (
{hasText && ( )} {canFork && ( )}
From da1a9ac62fd171a45c6aa879e03b3baa6ca400b5 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 17:25:04 +0800 Subject: [PATCH 12/27] fix: keep current session after fork --- web/src/components/SessionChat.tsx | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 9cac9812e6..32609cfb75 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -391,10 +391,6 @@ export function SessionChat(props: { variant: 'success', actionLabel: t('toast.action.openSession') }) - navigate({ - to: '/sessions/$sessionId', - params: { sessionId: newSessionId } - }) } catch (error) { haptic.notification('error') const message = error instanceof Error ? error.message : t('dialog.fork.failedDescription') @@ -405,7 +401,7 @@ export function SessionChat(props: { url: `/sessions/${props.session.id}` }) } - }, [addToast, haptic, navigate, props.api, props.session, t]) + }, [addToast, haptic, props.api, props.session, t]) const attachmentAdapter = useMemo(() => { if (!props.session.active) { From 98e79f723490dd9c89c3ef94f901d07e8a64f181 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 17:49:08 +0800 Subject: [PATCH 13/27] fix: prevent fork toast navigation --- .../components/AssistantChat/messages/AssistantMessage.tsx | 4 ++-- web/src/components/SessionChat.tsx | 7 ++----- web/src/lib/toast-context.tsx | 4 ++-- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.tsx index 5125e7cefc..0db7fbdbeb 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.tsx @@ -152,10 +152,10 @@ export function HappyAssistantMessage() { type="button" title="Fork from this response" aria-label="Fork from this response" - className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-[var(--app-border)] bg-[var(--app-bg)] shadow-sm hover:bg-[var(--app-subtle-bg)] active:bg-[var(--app-subtle-bg)] transition-colors" + className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-[var(--app-subtle-bg)] active:bg-[var(--app-subtle-bg)] transition-colors" onClick={() => ctx.onForkBeforeMessage!(seq)} > - + )} diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 32609cfb75..3e77af9ac1 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -381,15 +381,12 @@ export function SessionChat(props: { const handleForkBeforeMessage = useCallback(async (beforeSeq: number) => { try { - const newSessionId = await props.api.forkSession(props.session.id, { beforeSeq }) + await props.api.forkSession(props.session.id, { beforeSeq }) haptic.notification('success') addToast({ title: t('dialog.fork.successTitle'), body: t('dialog.fork.successDescription', { name: getOutlineTitle(props.session) }), - sessionId: newSessionId, - url: `/sessions/${newSessionId}`, - variant: 'success', - actionLabel: t('toast.action.openSession') + variant: 'success' }) } catch (error) { haptic.notification('error') diff --git a/web/src/lib/toast-context.tsx b/web/src/lib/toast-context.tsx index e37a0383ae..bc8cff40de 100644 --- a/web/src/lib/toast-context.tsx +++ b/web/src/lib/toast-context.tsx @@ -5,8 +5,8 @@ export type Toast = { id: string title: string body: string - sessionId: string - url: string + sessionId?: string + url?: string variant?: 'default' | 'success' actionLabel?: string } From b5d8b422a6a8c01dd785c1fb61660db54503034e Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 18:02:03 +0800 Subject: [PATCH 14/27] fix: keep all fork actions on current session --- web/src/components/SessionChat.tsx | 7 +++++-- web/src/hooks/mutations/useForkWithFeedback.ts | 1 - 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 3e77af9ac1..32609cfb75 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -381,12 +381,15 @@ export function SessionChat(props: { const handleForkBeforeMessage = useCallback(async (beforeSeq: number) => { try { - await props.api.forkSession(props.session.id, { beforeSeq }) + const newSessionId = await props.api.forkSession(props.session.id, { beforeSeq }) haptic.notification('success') addToast({ title: t('dialog.fork.successTitle'), body: t('dialog.fork.successDescription', { name: getOutlineTitle(props.session) }), - variant: 'success' + sessionId: newSessionId, + url: `/sessions/${newSessionId}`, + variant: 'success', + actionLabel: t('toast.action.openSession') }) } catch (error) { haptic.notification('error') diff --git a/web/src/hooks/mutations/useForkWithFeedback.ts b/web/src/hooks/mutations/useForkWithFeedback.ts index e26f9bfc7d..d4516a8f47 100644 --- a/web/src/hooks/mutations/useForkWithFeedback.ts +++ b/web/src/hooks/mutations/useForkWithFeedback.ts @@ -20,7 +20,6 @@ export function useForkWithFeedback( variant: 'success', actionLabel: t('toast.action.openSession') }) - onSuccess(newSessionId) } catch (error) { const message = error instanceof Error ? error.message : t('dialog.fork.failedDescription') addToast({ From 2b7f8849681c37f3f5faf9828b6b3b1a43aa18bb Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 18:05:36 +0800 Subject: [PATCH 15/27] fix: auto-apply web app updates --- web/src/main.tsx | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/web/src/main.tsx b/web/src/main.tsx index 31c1def667..2cd9881ac1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -52,15 +52,14 @@ async function bootstrap() { const updateSW = registerSW({ onNeedRefresh() { - if (confirm('New version available! Reload to update?')) { - updateSW(true) - } + updateSW(true) }, onOfflineReady() { console.log('App ready for offline use') }, onRegistered(registration) { if (registration) { + void registration.update() setInterval(() => { registration.update() }, 60 * 60 * 1000) From 4e589845be0c47a2e18bf34953410184460e7035 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 18:17:46 +0800 Subject: [PATCH 16/27] fix: suppress fork ready toast --- hub/src/notifications/notificationHub.test.ts | 47 +++++++++++++++++++ hub/src/notifications/notificationHub.ts | 11 +++++ hub/src/sync/syncEngine.ts | 7 +++ shared/src/schemas.ts | 4 ++ 4 files changed, 69 insertions(+) diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index b744debaf6..d6d90d2a7f 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -168,6 +168,53 @@ describe('NotificationHub', () => { hub.stop() }) + it('suppresses the first ready notification for forked sessions only', async () => { + const engine = new FakeSyncEngine() + const channel = new StubChannel() + const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { + permissionDebounceMs: 1, + readyCooldownMs: 1 + }) + + 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(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..46a132f71a 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -10,6 +10,7 @@ export class NotificationHub { private readonly lastKnownRequests: Map> = new Map() private readonly notificationDebounce: Map = new Map() private readonly lastReadyNotificationAt: Map = new Map() + private readonly suppressNextReadyNotification: Set = new Set() private unsubscribeSyncEvents: (() => void) | null = null constructor( @@ -55,6 +56,11 @@ export class NotificationHub { return } + if (event.type === 'session-forked') { + this.suppressNextReadyNotification.add(event.sessionId) + return + } + if (event.type === 'session-ended' && event.sessionId) { if (event.reason === 'completed') { this.sendSessionCompletion(event.sessionId, event.reason).catch((error) => { @@ -89,6 +95,7 @@ export class NotificationHub { } this.lastKnownRequests.delete(sessionId) this.lastReadyNotificationAt.delete(sessionId) + this.suppressNextReadyNotification.delete(sessionId) } private getNotifiableSession(sessionId: string): Session | null { @@ -153,6 +160,10 @@ export class NotificationHub { return } + if (this.suppressNextReadyNotification.delete(sessionId)) { + return + } + const now = Date.now() const last = this.lastReadyNotificationAt.get(sessionId) ?? 0 if (now - last < this.readyCooldownMs) { diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 2d1f0c1bfb..1c6c769b19 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -649,6 +649,13 @@ export class SyncEngine { return { type: 'error', message: spawnResult.message, code: 'fork_failed' } } + this.eventPublisher.emit({ + type: 'session-forked', + sessionId: spawnResult.sessionId, + sourceSessionId: sessionId, + namespace + }) + const becameActive = await this.waitForSessionActive(spawnResult.sessionId) if (!becameActive) { return { type: 'error', message: 'Session failed to become active', code: 'fork_failed' } 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() From 6d929c2b720777de0d63ec261b0597448eaaffe1 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 18:31:51 +0800 Subject: [PATCH 17/27] fix: suppress ready toast after forking --- hub/src/notifications/notificationHub.test.ts | 9 ++++++-- hub/src/notifications/notificationHub.ts | 15 ++++++++----- .../messages/AssistantMessage.test.tsx | 22 +++++++++++-------- 3 files changed, 30 insertions(+), 16 deletions(-) diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index d6d90d2a7f..df492d6485 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -168,12 +168,12 @@ describe('NotificationHub', () => { hub.stop() }) - it('suppresses the first ready notification for forked sessions only', async () => { + it('suppresses ready notifications for forked sessions during the ready cooldown', async () => { const engine = new FakeSyncEngine() const channel = new StubChannel() const hub = new NotificationHub(engine as unknown as SyncEngine, [channel], { permissionDebounceMs: 1, - readyCooldownMs: 1 + readyCooldownMs: 30 }) const session = createSession({ id: 'forked-session' }) @@ -210,6 +210,11 @@ describe('NotificationHub', () => { 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(1) hub.stop() diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index 46a132f71a..6e35149be1 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -10,7 +10,7 @@ export class NotificationHub { private readonly lastKnownRequests: Map> = new Map() private readonly notificationDebounce: Map = new Map() private readonly lastReadyNotificationAt: Map = new Map() - private readonly suppressNextReadyNotification: Set = new Set() + private readonly suppressReadyUntil: Map = new Map() private unsubscribeSyncEvents: (() => void) | null = null constructor( @@ -38,6 +38,7 @@ export class NotificationHub { this.notificationDebounce.clear() this.lastKnownRequests.clear() this.lastReadyNotificationAt.clear() + this.suppressReadyUntil.clear() } private handleSyncEvent(event: SyncEvent): void { @@ -57,7 +58,7 @@ export class NotificationHub { } if (event.type === 'session-forked') { - this.suppressNextReadyNotification.add(event.sessionId) + this.suppressReadyUntil.set(event.sessionId, Date.now() + this.readyCooldownMs) return } @@ -95,7 +96,7 @@ export class NotificationHub { } this.lastKnownRequests.delete(sessionId) this.lastReadyNotificationAt.delete(sessionId) - this.suppressNextReadyNotification.delete(sessionId) + this.suppressReadyUntil.delete(sessionId) } private getNotifiableSession(sessionId: string): Session | null { @@ -160,11 +161,15 @@ export class NotificationHub { return } - if (this.suppressNextReadyNotification.delete(sessionId)) { + const now = Date.now() + const suppressUntil = this.suppressReadyUntil.get(sessionId) ?? 0 + if (now < suppressUntil) { return } + if (suppressUntil > 0) { + this.suppressReadyUntil.delete(sessionId) + } - const now = Date.now() const last = this.lastReadyNotificationAt.get(sessionId) ?? 0 if (now - last < this.readyCooldownMs) { return diff --git a/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx index ec46804c78..9dc8891a56 100644 --- a/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx +++ b/web/src/components/AssistantChat/messages/AssistantMessage.test.tsx @@ -23,12 +23,16 @@ vi.mock('@assistant-ui/react', () => ({ })) vi.mock('@/components/assistant-ui/markdown-text', () => ({ - MarkdownText: ({ text }: { text: string }) => {text} + MarkdownText: () => null })) vi.mock('@/components/assistant-ui/reasoning', () => ({ - Reasoning: ({ text }: { text: string }) => {text}, - ReasoningGroup: ({ children }: { children: ReactNode }) =>
{children}
+ Reasoning: () => null, + ReasoningGroup: () => null +})) + +vi.mock('@/components/AssistantChat/messages/ToolMessage', () => ({ + HappyToolMessage: () => null })) function renderAssistantMessage(onForkBeforeMessage?: (seq: number) => void) { @@ -75,7 +79,7 @@ describe('HappyAssistantMessage fork action', () => { } as any }) - it('shows fork action for assistant responses with a seq', () => { + it('shows fork action for assistant messages with a seq', () => { const onForkBeforeMessage = vi.fn() renderAssistantMessage(onForkBeforeMessage) @@ -84,13 +88,13 @@ describe('HappyAssistantMessage fork action', () => { expect(onForkBeforeMessage).toHaveBeenCalledWith(8) }) - it('does not show fork action for tool-only assistant messages', () => { + it('does not show fork action without a seq', () => { state.message = { role: 'assistant', - id: 'tool:m1', - content: [{ type: 'tool-call', toolCallId: 't1', toolName: 'Tool', argsText: '{}' }], - metadata: { custom: { kind: 'tool', toolCallId: 't1' } } - } as any + id: 'assistant:m1', + content: [{ type: 'text', text: 'answer' }], + metadata: { custom: { kind: 'assistant' } } + } renderAssistantMessage(vi.fn()) From 71d9d91646e6c8d3af4fb90667972de1b866113e Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 18:46:32 +0800 Subject: [PATCH 18/27] fix: suppress fork bootstrap ready toast --- hub/src/notifications/notificationHub.test.ts | 6 ++++- hub/src/notifications/notificationHub.ts | 22 +++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/hub/src/notifications/notificationHub.test.ts b/hub/src/notifications/notificationHub.test.ts index df492d6485..4fa0965866 100644 --- a/hub/src/notifications/notificationHub.test.ts +++ b/hub/src/notifications/notificationHub.test.ts @@ -168,7 +168,7 @@ describe('NotificationHub', () => { hub.stop() }) - it('suppresses ready notifications for forked sessions during the ready cooldown', async () => { + 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], { @@ -213,6 +213,10 @@ describe('NotificationHub', () => { 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) diff --git a/hub/src/notifications/notificationHub.ts b/hub/src/notifications/notificationHub.ts index 6e35149be1..1630e0180e 100644 --- a/hub/src/notifications/notificationHub.ts +++ b/hub/src/notifications/notificationHub.ts @@ -11,6 +11,7 @@ export class NotificationHub { 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( @@ -39,6 +40,7 @@ export class NotificationHub { this.lastKnownRequests.clear() this.lastReadyNotificationAt.clear() this.suppressReadyUntil.clear() + this.forkedBootstrapReadySessions.clear() } private handleSyncEvent(event: SyncEvent): void { @@ -59,6 +61,7 @@ export class NotificationHub { if (event.type === 'session-forked') { this.suppressReadyUntil.set(event.sessionId, Date.now() + this.readyCooldownMs) + this.forkedBootstrapReadySessions.add(event.sessionId) return } @@ -74,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) }) @@ -97,6 +103,22 @@ 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 { From 0f8fea062dd995e6d4c53b0eeb58c27b9ad298d1 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 22:16:03 +0800 Subject: [PATCH 19/27] fix: harden codex fork flow Address review feedback covering correctness, ordering, and UX gaps in the fork pipeline: - syncEngine: clone+inherit run only after waitForSessionActive succeeds, removing the partial-success leak; clone/inherit failures surface as warnings on the success result rather than fork_failed; defer forkToken validation so historical fork (which carries the prefix inline) does not require codexSessionId; lower historical-fork payload cap to 512 KiB; add observability log when target session already has rows at clone time. - sessionCache.inheritSessionMetadata: retry on version-mismatch (3x) with small backoff so concurrent CLI metadata writes don't drop inheritance. - codexHistoryStore.addItem: compute seq atomically inside INSERT...SELECT so concurrent writes can't read the same MAX(seq); throw on corrupt raw_item rows; warn (don't drop silently) when INSERT OR IGNORE swallows a row. - messages.cloneSessionMessages: force non-null invoked_at on cloned rows so source-side queued messages aren't re-delivered to the fork. - codexRemoteLauncher: surface thread/fork failures to the user instead of silently falling back; export isAbortError; document the synthetic hapi-fork- contract on thread/resume(history). - web: ApiClient.forkSession returns { sessionId, warnings? } end to end; useForkWithFeedback drops the dead onSuccess callback and the redundant sessionId param; failure toast no longer self-navigates; Toast gains an 'error' variant; en/zh-CN gain dialog.fork.partialTitle. - routes/sessions: validate beforeSeq shape (positive integer) and propagate warnings from the engine. - runner+commands/codex: clean up the fork-history temp file in both consumer (CLI) and producer (runner crash fallback). Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/src/codex/codexRemoteLauncher.test.ts | 18 +++- cli/src/codex/codexRemoteLauncher.ts | 50 ++++++++--- cli/src/codex/runCodex.ts | 3 + cli/src/commands/codex.ts | 4 +- cli/src/runner/run.ts | 7 +- cli/src/utils/spawnWithAbort.ts | 2 +- hub/src/store/codexHistoryStore.ts | 50 +++++++---- hub/src/store/messages.ts | 10 ++- hub/src/sync/sessionCache.ts | 51 +++++++----- hub/src/sync/sessionModel.test.ts | 4 +- hub/src/sync/syncEngine.ts | 82 +++++++++++++++---- hub/src/web/routes/sessions.ts | 9 +- web/src/api/client.ts | 6 +- web/src/components/SessionChat.tsx | 12 ++- web/src/components/SessionHeader.tsx | 9 +- web/src/components/SessionList.tsx | 4 +- web/src/components/ui/Toast.tsx | 6 +- .../hooks/mutations/useForkWithFeedback.ts | 21 +++-- web/src/hooks/mutations/useSessionActions.ts | 2 +- web/src/lib/locales/en.ts | 1 + web/src/lib/locales/zh-CN.ts | 1 + web/src/lib/toast-context.tsx | 2 +- 22 files changed, 258 insertions(+), 96 deletions(-) diff --git a/cli/src/codex/codexRemoteLauncher.test.ts b/cli/src/codex/codexRemoteLauncher.test.ts index b7bcac34ea..a2f93118db 100644 --- a/cli/src/codex/codexRemoteLauncher.test.ts +++ b/cli/src/codex/codexRemoteLauncher.test.ts @@ -14,7 +14,8 @@ const harness = vi.hoisted(() => ({ compactThreadIds: [] as string[], suppressTurnCompletion: false, remainingThreadSystemErrors: 0, - forkCalls: [] as unknown[] + forkCalls: [] as unknown[], + resumeShouldThrow: null as Error | null })); vi.mock('react', () => ({ @@ -65,6 +66,9 @@ vi.mock('./codexAppServerClient', () => { 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' }; } @@ -292,6 +296,7 @@ describe('codexRemoteLauncher', () => { 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 () => { @@ -419,6 +424,17 @@ describe('codexRemoteLauncher', () => { 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, diff --git a/cli/src/codex/codexRemoteLauncher.ts b/cli/src/codex/codexRemoteLauncher.ts index 6ddb0c7e21..8a1ea3a001 100644 --- a/cli/src/codex/codexRemoteLauncher.ts +++ b/cli/src/codex/codexRemoteLauncher.ts @@ -23,6 +23,7 @@ import { 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; messageSeqs: number[] }; @@ -709,6 +710,10 @@ class CodexRemoteLauncher extends RemoteLauncherBase { 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 { @@ -735,20 +740,29 @@ class CodexRemoteLauncher extends RemoteLauncherBase { applyResolvedModel(resumeRecord?.model); sendReady(); } else if (session.forkSessionId) { - const forkResponse = await appServerClient.forkThread(buildThreadForkParams({ - threadId: session.forkSessionId, - cwd: session.path, - mode: buildInitialMode(), - mcpServers, - cliOverrides: session.codexCliOverrides - }), { - signal: this.abortController.signal - }); + 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('app-server thread/fork did not return thread.id'); + throw new Error('Codex fork failed: app-server thread/fork did not return thread.id'); } this.currentThreadId = forkedThreadId; session.onSessionFound(forkedThreadId); @@ -956,7 +970,17 @@ class CodexRemoteLauncher extends RemoteLauncherBase { applyResolvedModel(forkRecord?.model); logger.debug(`[Codex] Forked app-server thread ${forkCandidate} -> ${threadId ?? 'unknown'}`); } catch (error) { - logger.warn(`[Codex] Failed to fork app-server thread ${forkCandidate}, starting new thread`, 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 { @@ -1043,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/runCodex.ts b/cli/src/codex/runCodex.ts index 347fc5aede..c2b131d2cf 100644 --- a/cli/src/codex/runCodex.ts +++ b/cli/src/codex/runCodex.ts @@ -193,6 +193,9 @@ export async function runCodex(opts: { messageQueue.pushIsolateAndClear(isolatedCommandText, enhancedMode, localId, messageSeq); return; } + // 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); diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index b3ad2aa3ec..dcc6744be9 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -6,7 +6,8 @@ import type { CommandDefinition } from './types' import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort, ResponseItem } from '@/codex/appServerTypes' -import { readFile } from 'node:fs/promises' +import { readFile, rm } from 'node:fs/promises' +import { dirname } from 'node:path' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' function parseReasoningEffort(value: string): ReasoningEffort { @@ -99,6 +100,7 @@ export const codexCommand: CommandDefinition = { throw new Error('--fork-history-file must contain a JSON array') } options.forkHistory = parsed as ResponseItem[] + void rm(dirname(file), { recursive: true, force: true }).catch(() => undefined) } else { unknownArgs.push(arg) } diff --git a/cli/src/runner/run.ts b/cli/src/runner/run.ts index 6beaa34312..fd226e7fe9 100644 --- a/cli/src/runner/run.ts +++ b/cli/src/runner/run.ts @@ -379,8 +379,9 @@ 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)) { - const forkHistoryDir = await fs.mkdtemp(join(os.tmpdir(), 'hapi-codex-history-')); + forkHistoryDir = await fs.mkdtemp(join(os.tmpdir(), 'hapi-codex-history-')); forkHistoryFile = join(forkHistoryDir, 'history.json'); await fs.writeFile(forkHistoryFile, JSON.stringify(options.forkHistory)); } @@ -501,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); 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/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index 68ba5a1f81..770f39090d 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -23,17 +23,16 @@ export class CodexHistoryStore { } addItem(input: AddCodexHistoryItemInput): void { - const now = Date.now() - const row = this.db.prepare( - 'SELECT COALESCE(MAX(seq), 0) + 1 AS nextSeq FROM codex_history_items WHERE session_id = ?' - ).get(input.sessionId) as { nextSeq: number } - - this.db.prepare(` + // 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 - ) VALUES ( - @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, @@ -43,9 +42,13 @@ export class CodexHistoryStore { item_kind: input.itemKind, message_seq: input.messageSeq ?? null, raw_item: JSON.stringify(input.rawItem), - seq: row.nextSeq, - created_at: now + 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 { @@ -75,7 +78,7 @@ export class CodexHistoryStore { const beforeClause = nextUser ? 'AND seq < @nextUserSeq' : '' const rows = this.db.prepare(` - SELECT raw_item + SELECT seq, raw_item FROM codex_history_items WHERE session_id = @sessionId ${beforeClause} @@ -83,9 +86,9 @@ export class CodexHistoryStore { `).all({ sessionId, nextUserSeq: nextUser?.seq ?? null - }) as Array<{ raw_item: string }> + }) as Array<{ seq: number; raw_item: string }> - return rows.map((row) => safeJsonParse(row.raw_item)) + return parsePrefixRows(rows, sessionId) } getPrefixBeforeMessageSeq(sessionId: string, beforeSeq: number): unknown[] | null { @@ -104,13 +107,28 @@ export class CodexHistoryStore { } const rows = this.db.prepare(` - SELECT raw_item + SELECT seq, raw_item FROM codex_history_items WHERE session_id = ? AND seq < ? ORDER BY seq ASC - `).all(sessionId, cut.seq) as Array<{ raw_item: string }> + `).all(sessionId, cut.seq) as Array<{ seq: number; raw_item: string }> + + return parsePrefixRows(rows, sessionId) + } +} - return rows.map((row) => safeJsonParse(row.raw_item)) +// 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/messages.ts b/hub/src/store/messages.ts index dd41fdfe6f..a87ac0fa47 100644 --- a/hub/src/store/messages.ts +++ b/hub/src/store/messages.ts @@ -315,6 +315,9 @@ export function cloneSessionMessages( 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( @@ -339,6 +342,11 @@ export function cloneSessionMessages( 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, @@ -346,7 +354,7 @@ export function cloneSessionMessages( created_at: row.created_at, seq: targetMaxSeq + row.seq, local_id: localId, - invoked_at: row.invoked_at + invoked_at: invokedAt }) } diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 4cef048402..00e5ce927b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -461,33 +461,46 @@ export class SessionCache { async inheritSessionMetadata(sourceSessionId: string, targetSessionId: string): Promise { const source = this.sessions.get(sourceSessionId) ?? this.refreshSession(sourceSessionId) - const target = this.sessions.get(targetSessionId) ?? this.refreshSession(targetSessionId) - if (!source || !target) { + if (!source) { throw new Error('Session not found') } - const mergedMetadata = mergeSessionMetadata(source.metadata ?? null, target.metadata ?? null) - if (mergedMetadata === target.metadata) { - return - } + // 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 result = this.store.sessions.updateSessionMetadata( - targetSessionId, - mergedMetadata, - target.metadataVersion, - target.namespace, - { touchUpdatedAt: false } - ) + const mergedMetadata = mergeSessionMetadata(source.metadata ?? null, target.metadata ?? null) + if (mergedMetadata === target.metadata) { + return + } - if (result.result === 'error') { - throw new Error('Failed to inherit session metadata') - } + const result = this.store.sessions.updateSessionMetadata( + targetSessionId, + mergedMetadata, + target.metadataVersion, + target.namespace, + { touchUpdatedAt: false } + ) - if (result.result === 'version-mismatch') { - throw new Error('Session was modified concurrently. Please try again.') + if (result.result === 'success') { + this.refreshSession(targetSessionId) + return + } + if (result.result === 'error') { + throw new Error('Failed to inherit session metadata') + } } - this.refreshSession(targetSessionId) + throw new Error('Session was modified concurrently. Please try again.') } async deleteSession(sessionId: string): Promise { diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index effbb2daa6..45caa208aa 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -896,12 +896,12 @@ describe('session model', () => { expect(await engine.forkSession(session.id, 'default', { beforeSeq: 2 })).toMatchObject({ type: 'error', code: 'fork_unavailable', - message: '历史点 fork 只支持新版本会话' + 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: '历史点 fork 只支持新版本会话' + message: 'Historical fork is only supported for sessions started with the new Codex history pipeline' }) } finally { engine.stop() diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 1c6c769b19..3e6e0c2a4e 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -52,7 +52,7 @@ export type ResumeSessionResult = | { type: 'error'; message: string; code: 'session_not_found' | 'access_denied' | 'no_machine_online' | 'resume_unavailable' | 'resume_failed' } export type ForkSessionResult = - | { type: 'success'; sessionId: string } + | { 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 { @@ -572,11 +572,6 @@ export class SyncEngine { return { type: 'error', message: 'Fork is only supported for Codex sessions', code: 'fork_unavailable' } } - const forkToken = metadata.codexSessionId - if (!forkToken) { - return { type: 'error', message: 'Fork session ID unavailable', code: 'fork_unavailable' } - } - let forkHistory: unknown[] | undefined let cloneBeforeSeq: number | undefined if (opts?.beforeSeq !== undefined) { @@ -600,7 +595,29 @@ export class SyncEngine { if (!prefix) { return { type: 'error', - message: '历史点 fork 只支持新版本会话', + 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' } } @@ -608,6 +625,13 @@ export class SyncEngine { cloneBeforeSeq = this.store.messages.getNextUserMessageSeq(sessionId, userMessageSeq) ?? undefined } + // 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' } @@ -649,6 +673,18 @@ export class SyncEngine { 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, @@ -656,15 +692,33 @@ export class SyncEngine { namespace }) - const becameActive = await this.waitForSessionActive(spawnResult.sessionId) - if (!becameActive) { - return { type: 'error', message: 'Session failed to become active', code: 'fork_failed' } + // 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[] = [] + 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) + 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') + } + 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') } - this.store.messages.cloneSessionMessages(sessionId, spawnResult.sessionId, cloneBeforeSeq) - await this.sessionCache.inheritSessionMetadata(sessionId, spawnResult.sessionId) - - return { type: 'success', sessionId: spawnResult.sessionId } + return warnings.length > 0 + ? { type: 'success', sessionId: spawnResult.sessionId, warnings } + : { type: 'success', sessionId: spawnResult.sessionId } } async waitForSessionActive(sessionId: string, timeoutMs: number = 15_000): Promise { diff --git a/hub/src/web/routes/sessions.ts b/hub/src/web/routes/sessions.ts index 4b6be87af8..6a59d27660 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -157,7 +157,10 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho if (contentType.includes('application/json')) { const body = await c.req.json().catch(() => null) as { beforeSeq?: unknown } | null if (body && body.beforeSeq !== undefined) { - beforeSeq = typeof body.beforeSeq === 'number' ? body.beforeSeq : Number.NaN + 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 } } @@ -175,7 +178,9 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho return c.json({ error: result.message, code: result.code }, status) } - return c.json({ type: 'success', sessionId: result.sessionId }) + 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) => { diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 469a8d384d..1372c1d984 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -296,8 +296,8 @@ export class ApiClient { return response.sessionId } - async forkSession(sessionId: string, opts?: { beforeSeq?: number }): Promise { - const response = await this.request<{ sessionId: string }>( + 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', @@ -306,7 +306,7 @@ export class ApiClient { }) } ) - return response.sessionId + return { sessionId: response.sessionId, warnings: response.warnings } } async sendMessage(sessionId: string, text: string, localId?: string | null, attachments?: AttachmentMetadata[]): Promise { diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 32609cfb75..67232f7c90 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -381,7 +381,7 @@ export function SessionChat(props: { const handleForkBeforeMessage = useCallback(async (beforeSeq: number) => { try { - const newSessionId = await props.api.forkSession(props.session.id, { beforeSeq }) + const { sessionId: newSessionId, warnings } = await props.api.forkSession(props.session.id, { beforeSeq }) haptic.notification('success') addToast({ title: t('dialog.fork.successTitle'), @@ -391,14 +391,20 @@ export function SessionChat(props: { variant: 'success', actionLabel: t('toast.action.openSession') }) + if (warnings && warnings.length > 0) { + addToast({ + title: t('dialog.fork.partialTitle'), + body: warnings.join('; '), + variant: 'error' + }) + } } catch (error) { haptic.notification('error') const message = error instanceof Error ? error.message : t('dialog.fork.failedDescription') addToast({ title: t('dialog.fork.failedTitle'), body: message, - sessionId: props.session.id, - url: `/sessions/${props.session.id}` + variant: 'error' }) } }, [addToast, haptic, props.api, props.session, t]) diff --git a/web/src/components/SessionHeader.tsx b/web/src/components/SessionHeader.tsx index 97ddf6ea88..3e788a53c8 100644 --- a/web/src/components/SessionHeader.tsx +++ b/web/src/components/SessionHeader.tsx @@ -8,7 +8,6 @@ import { RenameSessionDialog } from '@/components/RenameSessionDialog' import { ConfirmDialog } from '@/components/ui/ConfirmDialog' import { getSessionModelLabel } from '@/lib/sessionModelLabel' import { useTranslation } from '@/lib/use-translation' -import { useNavigate } from '@tanstack/react-router' import { useForkWithFeedback } from '@/hooks/mutations/useForkWithFeedback' function getSessionTitle(session: Session): string { @@ -95,7 +94,6 @@ export function SessionHeader(props: { onSessionDeleted?: () => void }) { const { t } = useTranslation() - const navigate = useNavigate() const { session, api, onSessionDeleted } = props const title = useMemo(() => getSessionTitle(session), [session]) const worktreeBranch = session.metadata?.worktree?.branch @@ -116,7 +114,7 @@ export function SessionHeader(props: { session.metadata?.flavor ?? null ) - const handleFork = useForkWithFeedback(forkSession, session.id, title) + const handleFork = useForkWithFeedback(forkSession, title) const handleDelete = async () => { await deleteSession() @@ -227,10 +225,7 @@ export function SessionHeader(props: { sessionActive={session.active} canFork={canFork} onRename={() => setRenameOpen(true)} - onFork={() => handleFork((newId) => navigate({ - to: '/sessions/$sessionId', - params: { sessionId: newId } - }))} + onFork={handleFork} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} diff --git a/web/src/components/SessionList.tsx b/web/src/components/SessionList.tsx index dfabbc5510..8827facdcd 100644 --- a/web/src/components/SessionList.tsx +++ b/web/src/components/SessionList.tsx @@ -582,7 +582,7 @@ function SessionItem(props: { s.metadata?.flavor ?? null ) - const handleFork = useForkWithFeedback(forkSession, s.id, sessionName) + const handleFork = useForkWithFeedback(forkSession, sessionName) const longPressHandlers = useLongPress({ onLongPress: (point) => { @@ -648,7 +648,7 @@ function SessionItem(props: { sessionActive={s.active} canFork={canFork} onRename={() => setRenameOpen(true)} - onFork={() => handleFork(onSelect)} + onFork={handleFork} onArchive={() => setArchiveOpen(true)} onDelete={() => setDeleteOpen(true)} anchorPoint={menuAnchorPoint} diff --git a/web/src/components/ui/Toast.tsx b/web/src/components/ui/Toast.tsx index 681c9dac08..ebf650831b 100644 --- a/web/src/components/ui/Toast.tsx +++ b/web/src/components/ui/Toast.tsx @@ -8,7 +8,8 @@ const toastVariants = cva( variants: { variant: { 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' + 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: { @@ -23,7 +24,8 @@ const toastActionVariants = cva( 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' + 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: { diff --git a/web/src/hooks/mutations/useForkWithFeedback.ts b/web/src/hooks/mutations/useForkWithFeedback.ts index d4516a8f47..5888e46480 100644 --- a/web/src/hooks/mutations/useForkWithFeedback.ts +++ b/web/src/hooks/mutations/useForkWithFeedback.ts @@ -2,16 +2,17 @@ import { useToast } from '@/lib/toast-context' import { useTranslation } from '@/lib/use-translation' export function useForkWithFeedback( - forkSession: () => Promise, - sessionId: string, + forkSession: () => Promise<{ sessionId: string; warnings?: string[] }>, sessionName: string ) { const { addToast } = useToast() const { t } = useTranslation() - return async (onSuccess: (newSessionId: string) => void) => { + // Returns void: callers stay on the source session and rely on the toast's + // sessionId/actionLabel to surface a click-through to the new session. + return async () => { try { - const newSessionId = await forkSession() + const { sessionId: newSessionId, warnings } = await forkSession() addToast({ title: t('dialog.fork.successTitle'), body: t('dialog.fork.successDescription', { name: sessionName }), @@ -20,13 +21,21 @@ export function useForkWithFeedback( variant: 'success', actionLabel: t('toast.action.openSession') }) + if (warnings && warnings.length > 0) { + addToast({ + title: t('dialog.fork.partialTitle'), + body: warnings.join('; '), + variant: 'error' + }) + } } catch (error) { const message = error instanceof Error ? error.message : t('dialog.fork.failedDescription') + // No sessionId/url: clicking the toast on the page you're already on would just dismiss + // it, which is what the close affordance already does. addToast({ title: t('dialog.fork.failedTitle'), body: message, - sessionId, - url: `/sessions/${sessionId}` + variant: 'error' }) } } diff --git a/web/src/hooks/mutations/useSessionActions.ts b/web/src/hooks/mutations/useSessionActions.ts index 5daef28b07..8d55cdae16 100644 --- a/web/src/hooks/mutations/useSessionActions.ts +++ b/web/src/hooks/mutations/useSessionActions.ts @@ -14,7 +14,7 @@ export function useSessionActions( ): { abortSession: () => Promise archiveSession: () => Promise - forkSession: () => Promise + forkSession: () => Promise<{ sessionId: string; warnings?: string[] }> switchSession: () => Promise setPermissionMode: (mode: PermissionMode) => Promise setCollaborationMode: (mode: CodexCollaborationMode) => Promise diff --git a/web/src/lib/locales/en.ts b/web/src/lib/locales/en.ts index 0c456bcb3c..eda4f1abd1 100644 --- a/web/src/lib/locales/en.ts +++ b/web/src/lib/locales/en.ts @@ -95,6 +95,7 @@ export default { 'dialog.fork.successDescription': 'Created a new session from "{name}".', 'dialog.fork.failedTitle': 'Fork failed', 'dialog.fork.failedDescription': 'Unable to fork session. Please try again.', + 'dialog.fork.partialTitle': 'Fork created with warnings', 'toast.action.openSession': 'Open new session', 'dialog.delete.title': 'Delete Session', 'dialog.delete.description': 'Are you sure you want to delete "{name}"? This action cannot be undone.', diff --git a/web/src/lib/locales/zh-CN.ts b/web/src/lib/locales/zh-CN.ts index c49642ee97..90b8bf47ec 100644 --- a/web/src/lib/locales/zh-CN.ts +++ b/web/src/lib/locales/zh-CN.ts @@ -96,6 +96,7 @@ export default { 'dialog.fork.successDescription': '已从 "{name}" 创建新会话。', 'dialog.fork.failedTitle': 'Fork 失败', 'dialog.fork.failedDescription': '无法 Fork 会话,请重试。', + 'dialog.fork.partialTitle': 'Fork 已创建但存在警告', 'toast.action.openSession': '点击前往新会话', 'dialog.delete.title': '删除会话', diff --git a/web/src/lib/toast-context.tsx b/web/src/lib/toast-context.tsx index bc8cff40de..3933ce3e92 100644 --- a/web/src/lib/toast-context.tsx +++ b/web/src/lib/toast-context.tsx @@ -7,7 +7,7 @@ export type Toast = { body: string sessionId?: string url?: string - variant?: 'default' | 'success' + variant?: 'default' | 'success' | 'error' actionLabel?: string } From 06e2fc5b884a42881a28fa12092c3e479c50b5e0 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 22:41:59 +0800 Subject: [PATCH 20/27] fix: handle unavailable codex forks --- hub/src/web/routes/sessions.test.ts | 25 ++++++++++++++++++++++++- hub/src/web/routes/sessions.ts | 3 ++- web/src/components/SessionChat.tsx | 2 +- 3 files changed, 27 insertions(+), 3 deletions(-) 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 6a59d27660..9dcc8a0e14 100644 --- a/hub/src/web/routes/sessions.ts +++ b/hub/src/web/routes/sessions.ts @@ -174,7 +174,8 @@ export function createSessionsRoutes(getSyncEngine: () => SyncEngine | null): Ho const status = result.code === 'no_machine_online' ? 503 : result.code === 'access_denied' ? 403 : result.code === 'session_not_found' ? 404 - : 500 + : result.code === 'fork_unavailable' ? 409 + : 500 return c.json({ error: result.message, code: result.code }, status) } diff --git a/web/src/components/SessionChat.tsx b/web/src/components/SessionChat.tsx index 67232f7c90..720e283114 100644 --- a/web/src/components/SessionChat.tsx +++ b/web/src/components/SessionChat.tsx @@ -459,7 +459,7 @@ export function SessionChat(props: { disabled={sessionInactive} onRefresh={props.onRefresh} onRetryMessage={props.onRetryMessage} - onForkBeforeMessage={handleForkBeforeMessage} + onForkBeforeMessage={agentFlavor === 'codex' ? handleForkBeforeMessage : undefined} onFlushPending={props.onFlushPending} onAtBottomChange={props.onAtBottomChange} isLoadingMessages={props.isLoadingMessages} From 081b68c47ae2d2eed6593ba4aac2cd4c2a76cdb9 Mon Sep 17 00:00:00 2001 From: pppobear Date: Sun, 3 May 2026 22:48:09 +0800 Subject: [PATCH 21/27] fix: preserve codex fork reasoning effort --- hub/src/sync/sessionModel.test.ts | 9 +++++++-- hub/src/sync/syncEngine.ts | 2 +- 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 45caa208aa..340847728d 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -695,7 +695,9 @@ describe('session model', () => { }, null, 'default', - 'gpt-5.4' + 'gpt-5.4', + undefined, + 'xhigh' ) engine.getOrCreateMachine( 'machine-1', @@ -707,12 +709,13 @@ describe('session model', () => { 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, + modelReasoningEffort?: string, _yolo?: boolean, _sessionType?: 'simple' | 'worktree', _worktreeName?: string, @@ -720,6 +723,7 @@ describe('session model', () => { forkSessionId?: string ) => { capturedModel = model + capturedModelReasoningEffort = modelReasoningEffort capturedForkSessionId = forkSessionId const forkedSession = engine.getOrCreateSession( 'forked-session', @@ -743,6 +747,7 @@ describe('session model', () => { expect(result.type).toBe('success') expect(capturedForkSessionId).toBe('codex-thread-1') expect(capturedModel).toBe('gpt-5.4') + expect(capturedModelReasoningEffort).toBe('xhigh') } finally { engine.stop() } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 3e6e0c2a4e..33eaed71dc 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -658,7 +658,7 @@ export class SyncEngine { metadata.path, 'codex', session.model ?? undefined, - undefined, + session.modelReasoningEffort ?? undefined, undefined, undefined, undefined, From dfb3879d3a13cf5cf3aa64c159f39715094a7878 Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 00:14:44 +0800 Subject: [PATCH 22/27] fix: make user fork cut point precede turn --- hub/src/sync/sessionModel.test.ts | 110 +++++++++++++++++- hub/src/sync/syncEngine.ts | 6 +- .../messages/UserMessage.test.tsx | 8 +- 3 files changed, 117 insertions(+), 7 deletions(-) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 340847728d..1da55e057d 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -873,6 +873,114 @@ describe('session model', () => { } }) + 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( @@ -906,7 +1014,7 @@ describe('session model', () => { expect(await engine.forkSession(session.id, 'default', { beforeSeq: 1 })).toMatchObject({ type: 'error', code: 'fork_unavailable', - message: 'Historical fork is only supported for sessions started with the new Codex history pipeline' + message: 'No earlier history to fork from' }) } finally { engine.stop() diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 33eaed71dc..92ebb017a7 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -582,14 +582,16 @@ export class SyncEngine { const record = cutMessage ? unwrapRoleWrappedRecordEnvelope(cutMessage.content) : null const userMessageSeq = (() => { if (!cutMessage || !record) return null - if (record.role === 'user') return opts.beforeSeq + 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: 'Historical fork cut point must be an agent response', code: 'fork_unavailable' } + return { type: 'error', message: 'No earlier history to fork from', code: 'fork_unavailable' } } const prefix = this.store.codexHistory.getPrefixThroughReplyForUserMessageSeq(sessionId, userMessageSeq) if (!prefix) { diff --git a/web/src/components/AssistantChat/messages/UserMessage.test.tsx b/web/src/components/AssistantChat/messages/UserMessage.test.tsx index 712b855a67..b6f590afe3 100644 --- a/web/src/components/AssistantChat/messages/UserMessage.test.tsx +++ b/web/src/components/AssistantChat/messages/UserMessage.test.tsx @@ -69,12 +69,12 @@ describe('HappyUserMessage fork action', () => { } as any }) - it('does not show fork action for user messages with a seq', () => { + it('shows fork-before action for user messages with a seq', () => { const onForkBeforeMessage = vi.fn() renderUserMessage(onForkBeforeMessage) - expect(screen.queryByTitle('Fork from this response')).toBeNull() - expect(onForkBeforeMessage).not.toHaveBeenCalled() + screen.getByTitle('Fork before here').click() + expect(onForkBeforeMessage).toHaveBeenCalledWith(7) }) it('does not show fork action for non-user messages', () => { @@ -87,6 +87,6 @@ describe('HappyUserMessage fork action', () => { renderUserMessage(vi.fn()) - expect(screen.queryByTitle('Fork from this response')).toBeNull() + expect(screen.queryByTitle('Fork before here')).toBeNull() }) }) From b4023e348fb304d7f09f9d1f677d960c7377bd4f Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 00:32:29 +0800 Subject: [PATCH 23/27] fix: avoid deleting fork history directory --- cli/src/commands/codex.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/cli/src/commands/codex.ts b/cli/src/commands/codex.ts index dcc6744be9..624fb6e4e6 100644 --- a/cli/src/commands/codex.ts +++ b/cli/src/commands/codex.ts @@ -7,7 +7,6 @@ import { CODEX_PERMISSION_MODES } from '@hapi/protocol/modes' import type { CodexPermissionMode } from '@hapi/protocol/types' import type { ReasoningEffort, ResponseItem } from '@/codex/appServerTypes' import { readFile, rm } from 'node:fs/promises' -import { dirname } from 'node:path' import { assertCodexLocalSupported } from '@/codex/utils/codexVersion' function parseReasoningEffort(value: string): ReasoningEffort { @@ -100,7 +99,7 @@ export const codexCommand: CommandDefinition = { throw new Error('--fork-history-file must contain a JSON array') } options.forkHistory = parsed as ResponseItem[] - void rm(dirname(file), { recursive: true, force: true }).catch(() => undefined) + void rm(file, { force: true }).catch(() => undefined) } else { unknownArgs.push(arg) } From 6fef047e4aeecde767911c2d05649edd8e805d44 Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 00:47:52 +0800 Subject: [PATCH 24/27] fix: clone codex history for forked sessions --- hub/src/store/codexHistoryStore.test.ts | 37 +++++++++ hub/src/store/codexHistoryStore.ts | 100 ++++++++++++++++++++++++ hub/src/sync/sessionModel.test.ts | 22 +++++- hub/src/sync/syncEngine.ts | 21 +++++ 4 files changed, 177 insertions(+), 3 deletions(-) diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts index 1f1e27ed32..e2b729e6d8 100644 --- a/hub/src/store/codexHistoryStore.test.ts +++ b/hub/src/store/codexHistoryStore.test.ts @@ -104,6 +104,43 @@ describe('CodexHistoryStore', () => { 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('deletes codex history rows when deleting the session', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index 770f39090d..ba1c8df673 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -15,6 +15,17 @@ export type AddCodexHistoryItemInput = { rawItem: unknown } +type CodexHistoryRow = { + 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 @@ -116,6 +127,95 @@ export class CodexHistoryStore { return parsePrefixRows(rows, sessionId) } + + cloneSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number { + const rows = this.db.prepare(` + SELECT 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 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) + } + + 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 insert = 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 + ) 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 result = insert.run({ + id: randomUUID(), + session_id: toSessionId, + codex_thread_id: row.codex_thread_id, + turn_id: row.turn_id, + item_id: row.item_id, + 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 + }) + cloned += result.changes + } + + return cloned + } } // Throw on unparseable rows — forwarding null into thread/resume(history) would corrupt the prefix. diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 1da55e057d..e46ff2d7b8 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -821,6 +821,7 @@ describe('session model', () => { let capturedForkSessionId: string | undefined let capturedForkHistory: unknown[] | undefined + let spawnCount = 0 let forkedSessionId = '' ;(engine as any).rpcGateway.spawnSession = async ( _machineId: string, @@ -839,14 +840,15 @@ describe('session model', () => { ) => { capturedForkSessionId = forkSessionId capturedForkHistory = forkHistory + spawnCount += 1 const forkedSession = engine.getOrCreateSession( - 'forked-session-history-point', + `forked-session-history-point-${spawnCount}`, { path: '/tmp/project', host: 'localhost', machineId: 'machine-1', flavor: 'codex', - codexSessionId: 'codex-thread-2' + codexSessionId: `codex-thread-fork-${spawnCount}` }, null, 'default' @@ -860,7 +862,7 @@ describe('session model', () => { expect(result).toEqual({ type: 'success', sessionId: forkedSessionId }) expect(capturedForkSessionId).toBeUndefined() - expect(capturedForkHistory).toEqual([ + expect(capturedForkHistory as unknown).toEqual([ { id: 'user-1', role: 'user' }, { id: 'assistant-1', role: 'assistant' } ]) @@ -868,6 +870,20 @@ describe('session model', () => { { 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() } diff --git a/hub/src/sync/syncEngine.ts b/hub/src/sync/syncEngine.ts index 92ebb017a7..a395b0d520 100644 --- a/hub/src/sync/syncEngine.ts +++ b/hub/src/sync/syncEngine.ts @@ -574,6 +574,7 @@ export class SyncEngine { 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' } @@ -625,6 +626,7 @@ export class SyncEngine { } 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 @@ -697,12 +699,14 @@ export class SyncEngine { // 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.`) @@ -711,6 +715,23 @@ export class SyncEngine { 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) { From 72b9febb076a6aa7180c80a3dadb3a4c026f53c3 Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 01:21:14 +0800 Subject: [PATCH 25/27] fix: move codex history during session merge --- hub/src/store/codexHistoryStore.test.ts | 42 +++++++++++++++++++++++++ hub/src/store/codexHistoryStore.ts | 36 +++++++++++++++++++++ hub/src/sync/sessionCache.ts | 4 +++ hub/src/sync/sessionModel.test.ts | 23 ++++++++++++-- 4 files changed, 103 insertions(+), 2 deletions(-) diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts index e2b729e6d8..793b95e643 100644 --- a/hub/src/store/codexHistoryStore.test.ts +++ b/hub/src/store/codexHistoryStore.test.ts @@ -141,6 +141,48 @@ describe('CodexHistoryStore', () => { expect(store.codexHistory.getPrefixThroughReplyForUserMessageSeq(target.id, 1)).toBeNull() }) + 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('deletes codex history rows when deleting the session', () => { const store = new Store(':memory:') const session = store.sessions.getOrCreateSession('s1', { flavor: 'codex' }, null, 'default') diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index ba1c8df673..b397d7ddac 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -182,6 +182,42 @@ export class CodexHistoryStore { return this.cloneRows(toSessionId, rows, messageSeqOffset) } + moveSessionHistory(fromSessionId: string, toSessionId: string, targetMessageSeqOffset: number): number { + if (fromSessionId === toSessionId) return 0 + + const sourceMaxSeq = (this.db.prepare( + 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM codex_history_items WHERE session_id = ?' + ).get(fromSessionId) as { maxSeq: number } | undefined)?.maxSeq ?? 0 + + try { + this.db.exec('BEGIN') + + if (sourceMaxSeq > 0) { + this.db.prepare(` + UPDATE codex_history_items + SET seq = seq + ?, + message_seq = CASE + WHEN message_seq IS NULL THEN NULL + ELSE message_seq + ? + END + WHERE session_id = ? + `).run(sourceMaxSeq, targetMessageSeqOffset, toSessionId) + } + + const result = this.db.prepare(` + UPDATE OR IGNORE codex_history_items + SET session_id = ? + WHERE session_id = ? + `).run(toSessionId, fromSessionId) + + this.db.exec('COMMIT') + return result.changes + } catch (error) { + this.db.exec('ROLLBACK') + throw error + } + } + private cloneRows(toSessionId: string, rows: CodexHistoryRow[], messageSeqOffset: number): number { if (rows.length === 0) return 0 diff --git a/hub/src/sync/sessionCache.ts b/hub/src/sync/sessionCache.ts index 00e5ce927b..a35883c70b 100644 --- a/hub/src/sync/sessionCache.ts +++ b/hub/src/sync/sessionCache.ts @@ -559,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 }) diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index e46ff2d7b8..45219674c9 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1050,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', @@ -1069,6 +1084,10 @@ 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' } + ]) }) it('preserves sessions with different agent session IDs', async () => { From 3cd5054e626bc78bd581bbfd60c1e4f7fb18354d Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 02:07:36 +0800 Subject: [PATCH 26/27] fix: preserve moved codex history collisions --- hub/src/store/codexHistoryStore.test.ts | 70 +++++++++++++++++++++++ hub/src/store/codexHistoryStore.ts | 76 ++++++++++++++++++++----- hub/src/sync/sessionModel.test.ts | 14 +++++ 3 files changed, 147 insertions(+), 13 deletions(-) diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts index 793b95e643..ce5cc7dc99 100644 --- a/hub/src/store/codexHistoryStore.test.ts +++ b/hub/src/store/codexHistoryStore.test.ts @@ -183,6 +183,76 @@ describe('CodexHistoryStore', () => { ]) }) + 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') diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index b397d7ddac..c7e9f62ca2 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -16,6 +16,7 @@ export type AddCodexHistoryItemInput = { } type CodexHistoryRow = { + id?: string codex_thread_id: string turn_id: string | null item_id: string @@ -185,33 +186,82 @@ export class CodexHistoryStore { moveSessionHistory(fromSessionId: string, toSessionId: string, targetMessageSeqOffset: number): number { if (fromSessionId === toSessionId) return 0 - const sourceMaxSeq = (this.db.prepare( - 'SELECT COALESCE(MAX(seq), 0) AS maxSeq FROM codex_history_items WHERE session_id = ?' - ).get(fromSessionId) as { maxSeq: number } | undefined)?.maxSeq ?? 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 (sourceMaxSeq > 0) { + if (targetMessageSeqOffset !== 0) { this.db.prepare(` UPDATE codex_history_items - SET seq = seq + ?, - message_seq = CASE + SET message_seq = CASE WHEN message_seq IS NULL THEN NULL ELSE message_seq + ? END WHERE session_id = ? - `).run(sourceMaxSeq, targetMessageSeqOffset, toSessionId) + `).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 } - const result = this.db.prepare(` - UPDATE OR IGNORE codex_history_items - SET session_id = ? - WHERE session_id = ? - `).run(toSessionId, fromSessionId) + this.db.prepare( + 'DELETE FROM codex_history_items WHERE session_id = ?' + ).run(fromSessionId) this.db.exec('COMMIT') - return result.changes + return moved } catch (error) { this.db.exec('ROLLBACK') throw error diff --git a/hub/src/sync/sessionModel.test.ts b/hub/src/sync/sessionModel.test.ts index 45219674c9..578f78f352 100644 --- a/hub/src/sync/sessionModel.test.ts +++ b/hub/src/sync/sessionModel.test.ts @@ -1074,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) @@ -1088,6 +1097,11 @@ describe('session model', () => { { 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 () => { From 44a32b5c5573d8739fd4efc6d765ab79ab92334a Mon Sep 17 00:00:00 2001 From: pppobear Date: Mon, 4 May 2026 02:11:41 +0800 Subject: [PATCH 27/27] fix: preserve cloned codex history collisions --- hub/src/store/codexHistoryStore.test.ts | 37 +++++++++++++++++++++++++ hub/src/store/codexHistoryStore.ts | 22 +++++++++++---- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/hub/src/store/codexHistoryStore.test.ts b/hub/src/store/codexHistoryStore.test.ts index ce5cc7dc99..7259d077c1 100644 --- a/hub/src/store/codexHistoryStore.test.ts +++ b/hub/src/store/codexHistoryStore.test.ts @@ -141,6 +141,43 @@ describe('CodexHistoryStore', () => { 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') diff --git a/hub/src/store/codexHistoryStore.ts b/hub/src/store/codexHistoryStore.ts index c7e9f62ca2..b6e6f6ccce 100644 --- a/hub/src/store/codexHistoryStore.ts +++ b/hub/src/store/codexHistoryStore.ts @@ -131,7 +131,7 @@ export class CodexHistoryStore { cloneSessionHistory(fromSessionId: string, toSessionId: string, messageSeqOffset: number): number { const rows = this.db.prepare(` - SELECT codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + 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 @@ -170,7 +170,7 @@ export class CodexHistoryStore { const beforeClause = nextUser ? 'AND seq < @nextUserSeq' : '' const rows = this.db.prepare(` - SELECT codex_thread_id, turn_id, item_id, item_kind, message_seq, raw_item, seq, created_at + 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} @@ -275,8 +275,14 @@ export class CodexHistoryStore { '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 OR IGNORE INTO codex_history_items ( + 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 @@ -285,19 +291,23 @@ export class CodexHistoryStore { let cloned = 0 for (const row of rows) { - const result = insert.run({ + 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: row.item_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 }) - cloned += result.changes + existingItemIds.add(itemId) + cloned += 1 } return cloned