diff --git a/README.md b/README.md index 5f6976fe4..30fcc9ab4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,27 @@ Inside OpenClaude: > **Note:** OpenClaude does not automatically load project `.env` files. We recommend using the `/provider` command for setup, which saves provider profiles and credentials in `.openclaude-profile.json`. If you prefer environment variables, export them explicitly or run `openclaude --provider-env-file .env` for provider/setup variables. Export runtime/debug knobs from your shell or launcher. +### Resume or fork a conversation + +Resume an existing conversation by session ID, or continue the most recent +conversation in the current directory: + +```bash +openclaude --resume +openclaude --continue +``` + +Add `--fork-session` to branch the conversation history into a new session ID +instead of reusing the original transcript: + +```bash +openclaude --resume --fork-session +openclaude --continue --fork-session +``` + +Forking is conversation branching only. It does not create filesystem isolation, +copy your working tree, or create a git worktree branch. + ### Background sessions Run long non-interactive prompts detached from the current terminal: diff --git a/src/cli/print.ts b/src/cli/print.ts index 6e6a4dd16..0f2ecdda5 100644 --- a/src/cli/print.ts +++ b/src/cli/print.ts @@ -209,6 +209,7 @@ import { hydrateRemoteSession, hydrateFromCCRv2InternalEvents, resetSessionFilePointer, + recordContentReplacement, doesMessageExistInSession, findUnresolvedToolUse, recordAttributionSnapshot, @@ -308,9 +309,11 @@ import { fileHistoryGetDiffStats, } from 'src/utils/fileHistory.js' import { + createForkSessionInfoMessage, restoreAgentFromSession, restoreSessionStateFromLog, } from 'src/utils/sessionRestore.js' +import { filterContentReplacementsForMessages } from 'src/utils/toolResultStorage.js' import { SandboxManager } from 'src/utils/sandbox/sandbox-adapter.js' import { headlessProfilerStartTurn, @@ -5136,6 +5139,17 @@ async function loadInitialMessages( await resetSessionFilePointer() } } + } else { + if (persistSession && result.contentReplacements?.length) { + result.contentReplacements = filterContentReplacementsForMessages( + result.messages, + result.contentReplacements, + ) + if (result.contentReplacements.length) { + await recordContentReplacement(result.contentReplacements) + } + } + result.messages.push(createForkSessionInfoMessage(result.sessionId)) } restoreSessionStateFromLog(result, setAppState) @@ -5362,6 +5376,21 @@ async function loadInitialMessages( if (persistSession) { await resetSessionFilePointer() } + } else if (options.forkSession) { + if (persistSession && result.contentReplacements?.length) { + result.contentReplacements = filterContentReplacementsForMessages( + result.messages, + result.contentReplacements, + ) + if (result.contentReplacements.length) { + await recordContentReplacement(result.contentReplacements) + } + } + result.messages.push( + createForkSessionInfoMessage( + parsedSessionId?.sessionId ?? result.sessionId, + ), + ) } restoreSessionStateFromLog(result, setAppState) diff --git a/src/main.tsx b/src/main.tsx index 137d40dfb..0f355eed8 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -954,7 +954,11 @@ async function run(): Promise { throw new Error('--task-budget must be a positive integer'); } return tokens; - }).hideHelp()).option('--replay-user-messages', 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', () => true).addOption(new Option('--enable-auth-status', 'Enable auth status messages in SDK mode').default(false).hideHelp()).option('--allowedTools, --allowed-tools ', 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")').option('--tools ', 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").').option('--disallowedTools, --disallowed-tools ', 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")').option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)').addOption(new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)').argParser(String).hideHelp()).addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)).addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()).addOption(new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser(String)).addOption(new Option('--append-system-prompt-file ', 'Read system prompt from a file and append to the default system prompt').argParser(String).hideHelp()).addOption(new Option('--permission-mode ', 'Permission mode to use for the session').argParser(String).choices(PERMISSION_MODES)).option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true).option('-r, --resume [value]', 'Resume a conversation by session ID, or open interactive picker with optional search term', value => value || true).option('--fork-session', 'When resuming, create a new session ID instead of reusing the original (use with --resume or --continue)', () => true).addOption(new Option('--prefill ', 'Pre-fill the prompt input with text without submitting it').hideHelp()).addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()).addOption(new Option('--deep-link-repo ', 'Repo slug the deep link ?repo= parameter resolved to the current cwd').hideHelp()).addOption(new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline').argParser(v => { + }).hideHelp()).option('--replay-user-messages', 'Re-emit user messages from stdin back on stdout for acknowledgment (only works with --input-format=stream-json and --output-format=stream-json)', () => true).addOption(new Option('--enable-auth-status', 'Enable auth status messages in SDK mode').default(false).hideHelp()).option('--allowedTools, --allowed-tools ', 'Comma or space-separated list of tool names to allow (e.g. "Bash(git:*) Edit")').option('--tools ', 'Specify the list of available tools from the built-in set. Use "" to disable all tools, "default" to use all tools, or specify tool names (e.g. "Bash,Edit,Read").').option('--disallowedTools, --disallowed-tools ', 'Comma or space-separated list of tool names to deny (e.g. "Bash(git:*) Edit")').option('--mcp-config ', 'Load MCP servers from JSON files or strings (space-separated)').addOption(new Option('--permission-prompt-tool ', 'MCP tool to use for permission prompts (only works with --print)').argParser(String).hideHelp()).addOption(new Option('--system-prompt ', 'System prompt to use for the session').argParser(String)).addOption(new Option('--system-prompt-file ', 'Read system prompt from a file').argParser(String).hideHelp()).addOption(new Option('--append-system-prompt ', 'Append a system prompt to the default system prompt').argParser(String)).addOption(new Option('--append-system-prompt-file ', 'Read system prompt from a file and append to the default system prompt').argParser(String).hideHelp()).addOption(new Option('--permission-mode ', 'Permission mode to use for the session').argParser(String).choices(PERMISSION_MODES)) + .option('-c, --continue', 'Continue the most recent conversation in the current directory', () => true) + .option('-r, --resume [value]', 'Resume a conversation by session ID, or open interactive picker with optional search term', value => value || true) + .option('--fork-session', 'When resuming, branch the conversation into a new session ID; does not create filesystem or worktree isolation (use with --resume or --continue)', () => true) + .addOption(new Option('--prefill ', 'Pre-fill the prompt input with text without submitting it').hideHelp()).addOption(new Option('--deep-link-origin', 'Signal that this session was launched from a deep link').hideHelp()).addOption(new Option('--deep-link-repo ', 'Repo slug the deep link ?repo= parameter resolved to the current cwd').hideHelp()).addOption(new Option('--deep-link-last-fetch ', 'FETCH_HEAD mtime in epoch ms, precomputed by the deep link trampoline').argParser(v => { const n = Number(v); return Number.isFinite(n) ? n : undefined; }).hideHelp()).option('--from-pr [value]', 'Resume a session linked to a PR by PR number/URL, or open interactive picker with optional search term', value => value || true).option('--no-session-persistence', 'Disable session persistence - sessions will not be saved to disk and cannot be resumed (only works with --print)').addOption(new Option('--resume-session-at ', 'When resuming, only messages up to and including the assistant message with (use with --resume in print mode)').argParser(String).hideHelp()).addOption(new Option('--rewind-files ', 'Restore files to state at the specified user message and exit (requires --resume)').hideHelp()) diff --git a/src/screens/ResumeConversation.tsx b/src/screens/ResumeConversation.tsx index 08b44fba1..195c811d8 100644 --- a/src/screens/ResumeConversation.tsx +++ b/src/screens/ResumeConversation.tsx @@ -29,11 +29,11 @@ import { errorMessage } from '../utils/errors.js'; import type { FileHistorySnapshot } from '../utils/fileHistory.js'; import { logError } from '../utils/log.js'; import { createSystemMessage } from '../utils/messages.js'; -import { computeStandaloneAgentContext, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; +import { computeStandaloneAgentContext, createForkSessionInfoMessage, restoreAgentFromSession, restoreWorktreeForResume } from '../utils/sessionRestore.js'; import { adoptResumedSessionFile, enrichLogs, isCustomTitleEnabled, loadAllProjectsMessageLogsProgressive, loadSameRepoMessageLogsProgressive, recordContentReplacement, resetSessionFilePointer, restoreSessionMetadata, type SessionLogResult } from '../utils/sessionStorage.js'; import type { ModelSetting } from '../utils/model/model.js'; import type { ThinkingConfig } from '../utils/thinking.js'; -import type { ContentReplacementRecord } from '../utils/toolResultStorage.js'; +import { filterContentReplacementsForMessages, type ContentReplacementRecord } from '../utils/toolResultStorage.js'; import { REPL } from './REPL.js'; function parsePrIdentifier(value: string): number | null { const directNumber = parseInt(value, 10); @@ -231,7 +231,13 @@ export function ResumeConversation({ await resetSessionFilePointer(); restoreCostStateForSession(result_3.sessionId); } else if (forkSession && result_3.contentReplacements?.length) { - await recordContentReplacement(result_3.contentReplacements); + result_3.contentReplacements = filterContentReplacementsForMessages(result_3.messages, result_3.contentReplacements); + if (result_3.contentReplacements.length) { + await recordContentReplacement(result_3.contentReplacements); + } + } + if (forkSession) { + result_3.messages.push(createForkSessionInfoMessage(result_3.sessionId ?? log_0.sessionId)); } const { agentDefinition: resolvedAgentDef diff --git a/src/utils/sessionRestore.test.ts b/src/utils/sessionRestore.test.ts new file mode 100644 index 000000000..c2ed950a4 --- /dev/null +++ b/src/utils/sessionRestore.test.ts @@ -0,0 +1,424 @@ +import { afterEach, beforeEach, describe, expect, test } from 'bun:test' +import type { UUID } from 'node:crypto' +import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' + +import { + getSessionId, + getSessionProjectDir, + isSessionPersistenceDisabled, + setSessionPersistenceDisabled, + switchSession, +} from '../bootstrap/state.js' +import type { AppState } from '../state/AppStateStore.js' +import { getDefaultAppState } from '../state/AppStateStore.js' +import { + acquireSharedMutationLock, + releaseSharedMutationLock, +} from '../test/sharedMutationLock.js' +import type { PersistedWorktreeSession } from '../types/logs.js' +import type { Message } from '../types/message.js' +import { + flushSessionStorage, + getTranscriptPath, + loadTranscriptFile, + recordTranscript, + resetProjectForTesting, + resetSessionFilePointer, +} from './sessionStorage.ts' +import { + processResumedConversation, + type ProcessedResume, +} from './sessionRestore.js' +import type { ContentReplacementRecord } from './toolResultStorage.js' + +const tempDirs: string[] = [] +const ts = '2026-04-02T00:00:00.000Z' +const sourceSessionId = '00000000-0000-4000-8000-000000000111' +const forkSessionId = '00000000-0000-4000-8000-000000000222' + +let originalNodeEnv: string | undefined +let originalTestPersistence: string | undefined +let originalPersistence: string | undefined +let originalSkipPromptHistory: string | undefined +let originalSessionId: string +let originalSessionProjectDir: string | null +let originalPersistenceDisabled: boolean + +function id(n: number): UUID { + return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}` as UUID +} + +function userMessage(uuid: UUID, parentUuid: UUID | null, content: string) { + return { + uuid, + parentUuid, + timestamp: ts, + type: 'user', + isMeta: false, + message: { + role: 'user', + content, + }, + } as unknown as Message +} + +function assistantMessage( + uuid: UUID, + parentUuid: UUID | null, + content: string, +) { + return { + uuid, + parentUuid, + timestamp: ts, + type: 'assistant', + message: { + id: uuid, + type: 'message', + role: 'assistant', + content: [{ type: 'text', text: content }], + model: 'test-model', + stop_reason: 'end_turn', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as unknown as Message +} + +function assistantToolUseMessage( + uuid: UUID, + parentUuid: UUID | null, + toolUseId: string, +) { + return { + uuid, + parentUuid, + timestamp: ts, + type: 'assistant', + message: { + id: uuid, + type: 'message', + role: 'assistant', + content: [ + { + type: 'tool_use', + id: toolUseId, + name: 'Read', + input: { file_path: 'file.txt' }, + }, + ], + model: 'test-model', + stop_reason: 'tool_use', + usage: { + input_tokens: 1, + output_tokens: 1, + cache_creation_input_tokens: 0, + cache_read_input_tokens: 0, + }, + }, + } as unknown as Message +} + +function toolResultUserMessage( + uuid: UUID, + parentUuid: UUID | null, + toolUseId: string, +) { + return { + uuid, + parentUuid, + timestamp: ts, + type: 'user', + isMeta: false, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: 'full tool output', + is_error: false, + }, + ], + }, + toolUseResult: { + stdout: 'full tool output', + stderr: '', + }, + } as unknown as Message +} + +function sourceMessages(): Message[] { + return [ + userMessage(id(1), null, 'source prompt'), + assistantMessage(id(2), id(1), 'source response'), + ] +} + +function sourceMessagesWithToolResult(): Message[] { + return [ + userMessage(id(1), null, 'source prompt'), + assistantToolUseMessage(id(2), id(1), 'tool-use-1'), + toolResultUserMessage(id(3), id(2), 'tool-use-1'), + assistantMessage(id(4), id(3), 'source response'), + ] +} + +function testContext(initialState: AppState = getDefaultAppState()) { + return { + modeApi: null, + mainThreadAgentDefinition: undefined, + agentDefinitions: { activeAgents: [], allAgents: [] }, + currentCwd: '/tmp', + cliAgents: [], + initialState, + } +} + +async function createTempProject(): Promise { + const dir = await mkdtemp(join(tmpdir(), 'openclaude-session-restore-')) + tempDirs.push(dir) + return dir +} + +async function writeSourceTranscript( + dir: string, + messages: Message[] = sourceMessages(), +): Promise { + const filePath = join(dir, `${sourceSessionId}.jsonl`) + const entries = messages.map(message => ({ + ...message, + cwd: dir, + userType: 'external', + sessionId: sourceSessionId as UUID, + version: 'test', + isSidechain: false, + })) + await writeFile( + filePath, + `${entries.map(entry => JSON.stringify(entry)).join('\n')}\n`, + ) + return filePath +} + +function forkInfoMessage(messages: Message[]): Message | undefined { + return messages.find( + message => + message.type === 'system' && + message.subtype === 'informational' && + message.level === 'info' && + message.content.includes('Forked conversation'), + ) +} + +async function processForkedResume( + options: { + sessionIdOverride?: string + transcriptPath?: string + messages?: Message[] + contentReplacements?: ContentReplacementRecord[] + worktreeSession?: PersistedWorktreeSession | null + } = {}, +): Promise { + return processResumedConversation( + { + messages: options.messages ?? sourceMessages(), + sessionId: sourceSessionId as UUID, + contentReplacements: options.contentReplacements, + worktreeSession: options.worktreeSession, + }, + { + forkSession: true, + sessionIdOverride: options.sessionIdOverride, + transcriptPath: options.transcriptPath, + }, + testContext(), + ) +} + +beforeEach(async () => { + await acquireSharedMutationLock('utils/sessionRestore.test.ts') + originalNodeEnv = process.env.NODE_ENV + originalTestPersistence = process.env.TEST_ENABLE_SESSION_PERSISTENCE + originalPersistence = process.env.ENABLE_SESSION_PERSISTENCE + originalSkipPromptHistory = process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + originalSessionId = getSessionId() + originalSessionProjectDir = getSessionProjectDir() + originalPersistenceDisabled = isSessionPersistenceDisabled() + + process.env.NODE_ENV = 'test' + process.env.TEST_ENABLE_SESSION_PERSISTENCE = 'true' + process.env.ENABLE_SESSION_PERSISTENCE = 'true' + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + setSessionPersistenceDisabled(false) + resetProjectForTesting() +}) + +afterEach(async () => { + try { + await flushSessionStorage() + await Promise.all( + tempDirs.splice(0).map(dir => rm(dir, { recursive: true, force: true })), + ) + } finally { + if (originalNodeEnv === undefined) { + delete process.env.NODE_ENV + } else { + process.env.NODE_ENV = originalNodeEnv + } + if (originalTestPersistence === undefined) { + delete process.env.TEST_ENABLE_SESSION_PERSISTENCE + } else { + process.env.TEST_ENABLE_SESSION_PERSISTENCE = originalTestPersistence + } + if (originalPersistence === undefined) { + delete process.env.ENABLE_SESSION_PERSISTENCE + } else { + process.env.ENABLE_SESSION_PERSISTENCE = originalPersistence + } + if (originalSkipPromptHistory === undefined) { + delete process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY + } else { + process.env.CLAUDE_CODE_SKIP_PROMPT_HISTORY = + originalSkipPromptHistory + } + setSessionPersistenceDisabled(originalPersistenceDisabled) + switchSession(originalSessionId as never, originalSessionProjectDir) + resetProjectForTesting() + releaseSharedMutationLock() + } +}) + +describe('forked session resume', () => { + test('--continue --fork-session keeps the startup session id and adds an info message', async () => { + const dir = await createTempProject() + switchSession(forkSessionId as never, dir) + await resetSessionFilePointer() + + const result = await processForkedResume() + + expect(String(getSessionId())).toBe(forkSessionId) + const message = forkInfoMessage(result.messages) + expect(message?.content).toContain(sourceSessionId) + expect(message?.content).toContain(forkSessionId) + expect(message?.content).toContain('conversation branching') + expect(message?.content).toContain('not filesystem isolation') + }) + + test('--resume --fork-session keeps the startup session id and reports the selected source id', async () => { + const dir = await createTempProject() + switchSession(forkSessionId as never, dir) + await resetSessionFilePointer() + + const result = await processForkedResume({ + sessionIdOverride: sourceSessionId, + }) + + expect(String(getSessionId())).toBe(forkSessionId) + const message = forkInfoMessage(result.messages) + expect(message?.content).toContain(sourceSessionId) + expect(message?.content).toContain(forkSessionId) + }) + + test('fork materializes a new transcript with replacement records and no original worktree ownership', async () => { + const dir = await createTempProject() + const messages = sourceMessagesWithToolResult() + const sourceFile = await writeSourceTranscript(dir, messages) + const sourceBefore = await readFile(sourceFile, 'utf8') + const replacement: ContentReplacementRecord = { + kind: 'tool-result', + toolUseId: 'tool-use-1', + replacement: '[persisted tool result preview]', + } + const droppedReplacement: ContentReplacementRecord = { + kind: 'tool-result', + toolUseId: 'tool-use-2', + replacement: '[stale tool result preview]', + } + const sourceWorktree: PersistedWorktreeSession = { + originalCwd: '/repo', + worktreePath: '/repo/.worktrees/source', + worktreeName: 'source', + worktreeBranch: 'feature/source', + originalBranch: 'main', + originalHeadCommit: 'abcdef', + sessionId: sourceSessionId as UUID, + } + + switchSession(forkSessionId as never, dir) + await resetSessionFilePointer() + + const result = await processForkedResume({ + transcriptPath: sourceFile, + messages, + contentReplacements: [replacement, droppedReplacement], + worktreeSession: sourceWorktree, + }) + expect(result.contentReplacements).toEqual([replacement]) + await recordTranscript(result.messages) + await flushSessionStorage() + + const sourceAfter = await readFile(sourceFile, 'utf8') + expect(sourceAfter).toBe(sourceBefore) + + const forkFile = join(dir, `${forkSessionId}.jsonl`) + expect(getTranscriptPath()).toBe(forkFile) + const forkText = await readFile(forkFile, 'utf8') + const forkEntries = forkText + .trim() + .split('\n') + .map(line => JSON.parse(line) as { type?: string; sessionId?: string }) + + expect( + forkEntries + .filter(entry => entry.type === 'user' || entry.type === 'assistant') + .every(entry => entry.sessionId === forkSessionId), + ).toBe(true) + expect( + forkEntries.some(entry => entry.type === 'content-replacement'), + ).toBe(true) + expect( + forkEntries.some(entry => entry.type === 'worktree-state'), + ).toBe(false) + + const loaded = await loadTranscriptFile(forkFile) + expect(loaded.contentReplacements.get(forkSessionId as UUID)).toEqual([ + replacement, + ]) + expect(JSON.stringify(forkEntries)).not.toContain('tool-use-2') + expect( + loaded.contentReplacements.get(sourceSessionId as UUID), + ).toBeUndefined() + }) + + test('normal resume switches to and adopts the source transcript without adding fork metadata', async () => { + const dir = await createTempProject() + const sourceFile = await writeSourceTranscript(dir) + + switchSession(forkSessionId as never, dir) + await resetSessionFilePointer() + + const result = await processResumedConversation( + { + messages: sourceMessages(), + sessionId: sourceSessionId as UUID, + }, + { + forkSession: false, + sessionIdOverride: sourceSessionId, + transcriptPath: sourceFile, + }, + testContext(), + ) + + expect(String(getSessionId())).toBe(sourceSessionId) + expect(getTranscriptPath()).toBe(sourceFile) + expect(forkInfoMessage(result.messages)).toBeUndefined() + }) +}) diff --git a/src/utils/sessionRestore.ts b/src/utils/sessionRestore.ts index 4ad86ceea..6bfcd9d43 100644 --- a/src/utils/sessionRestore.ts +++ b/src/utils/sessionRestore.ts @@ -57,7 +57,10 @@ import type { GoalState } from '../services/goal/types.js' import { isTodoV2Enabled } from './tasks.js' import type { TodoList } from './todo/types.js' import { TodoListSchema } from './todo/types.js' -import type { ContentReplacementRecord } from './toolResultStorage.js' +import { + filterContentReplacementsForMessages, + type ContentReplacementRecord, +} from './toolResultStorage.js' import { getCurrentWorktreeSession, restoreWorktreeSession, @@ -321,6 +324,16 @@ type ResumeLoadResult = { goal?: GoalState | null } +export function createForkSessionInfoMessage( + sourceSessionId: string | undefined, + newSessionId: string = getSessionId(), +): Message { + return createSystemMessage( + `Forked conversation from session ${sourceSessionId ?? 'unknown'} into new session ${newSessionId}. This is conversation branching, not filesystem isolation; no worktree branch or filesystem copy was created.`, + 'info', + ) +} + /** * Restore the worktree working directory on resume. The transcript records * the last worktree enter/exit; if the session crashed while inside a @@ -466,7 +479,20 @@ export async function processResumedConversation( // → they're classified as FROZEN → full content sent (cache miss, permanent // overage). insertContentReplacement stamps sessionId = getSessionId() = // the fresh ID, so loadTranscriptFile's keyed lookup will match. - await recordContentReplacement(result.contentReplacements) + result.contentReplacements = filterContentReplacementsForMessages( + result.messages, + result.contentReplacements, + ) + if (result.contentReplacements.length) { + await recordContentReplacement(result.contentReplacements) + } + } + if (opts.forkSession) { + result.messages.push( + createForkSessionInfoMessage( + opts.sessionIdOverride ?? result.sessionId, + ), + ) } // Restore session metadata so /status shows the saved name and metadata diff --git a/src/utils/toolResultStorage.test.ts b/src/utils/toolResultStorage.test.ts index 8e5dca1b1..495cab362 100644 --- a/src/utils/toolResultStorage.test.ts +++ b/src/utils/toolResultStorage.test.ts @@ -1,7 +1,10 @@ import { expect, test } from 'bun:test' import { createUserMessage } from './messages.ts' -import { applyToolResultReplacementsToMessages } from './toolResultStorage.ts' +import { + applyToolResultReplacementsToMessages, + filterContentReplacementsForMessages, +} from './toolResultStorage.ts' test('applyToolResultReplacementsToMessages replaces matching tool results and preserves unrelated messages', () => { const unrelated = createUserMessage({ content: 'keep me' }) @@ -57,3 +60,32 @@ test('applyToolResultReplacementsToMessages is idempotent when messages are alre expect(next).toBe(messages) }) + +test('filterContentReplacementsForMessages keeps only records for retained tool results', () => { + const retained = createUserMessage({ + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'large retained output', + is_error: false, + }, + ], + }) + const kept = { + kind: 'tool-result' as const, + toolUseId: 'tool-1', + replacement: '[retained preview]', + } + + expect( + filterContentReplacementsForMessages([retained], [ + kept, + { + kind: 'tool-result', + toolUseId: 'tool-2', + replacement: '[dropped preview]', + }, + ]), + ).toEqual([kept]) +}) diff --git a/src/utils/toolResultStorage.ts b/src/utils/toolResultStorage.ts index 9efc80706..acb882c63 100644 --- a/src/utils/toolResultStorage.ts +++ b/src/utils/toolResultStorage.ts @@ -1015,6 +1015,28 @@ export function reconstructContentReplacementState( return state } +export function filterContentReplacementsForMessages( + messages: Message[], + records: ContentReplacementRecord[], +): ContentReplacementRecord[] { + const retainedToolResultIds = new Set() + for (const message of messages) { + if (message.type !== 'user' || !Array.isArray(message.message.content)) { + continue + } + for (const block of message.message.content) { + if (block.type === 'tool_result') { + retainedToolResultIds.add(block.tool_use_id) + } + } + } + return records.filter( + record => + record.kind === 'tool-result' && + retainedToolResultIds.has(record.toolUseId), + ) +} + /** * AgentTool-resume variant: encapsulates the feature-flag gate + parent * gap-fill so both AgentTool.call and resumeAgentBackground share one diff --git a/web/src/data/cliFlags.ts b/web/src/data/cliFlags.ts index 5151cfcfc..93d8dd097 100644 --- a/web/src/data/cliFlags.ts +++ b/web/src/data/cliFlags.ts @@ -65,7 +65,7 @@ export const flagGroups: FlagGroup[] = [ flags: [ { flag: '-c, --continue', description: 'Continue the most recent conversation in the current directory.' }, { flag: '-r, --resume', arg: '[id]', description: 'Resume a conversation by session ID, or open the interactive picker with an optional search term.' }, - { flag: '--fork-session', description: 'When resuming, create a new session ID instead of reusing the original.' }, + { flag: '--fork-session', description: 'When resuming, branch the conversation into a new session ID. This does not create filesystem or worktree isolation.' }, { flag: '--from-pr', arg: '[pr]', description: 'Resume a session linked to a PR by number/URL, or open the interactive picker.' }, { flag: '--session-id', arg: '', description: 'Use a specific session ID for the conversation (must be a valid UUID).' }, { flag: '-n, --name', arg: '', description: 'Set a display name for this session (shown in /resume and the terminal title).' },