diff --git a/README.md b/README.md index 56aa6de51..5f4a115e3 100644 --- a/README.md +++ b/README.md @@ -223,6 +223,19 @@ then `d` again to delete it; any other key in between cancels. Deleting removes the transcript and its sidecar files from disk and cannot be undone. The session you are currently in is not listed and cannot be deleted. +In `/resume`, type a search query, then press Down and Enter on **Search deeply +using a background model** to search conversation contents with the background +search model, which can differ from the active chat model. This explicit action +sends transcript excerpts and session metadata to the model provider, including +from loaded sessions that do not match the query locally. Ordinary filtering +stays local. Deep search considers up to 100 loaded sessions within the active +filters, so older history may not be included. Transcript extraction uses up to +100 messages (the first 50 and last 50 for longer sessions), then keeps only the +first 2,000 extracted characters. Matches outside that excerpt may be missed. +Results remain available as more history loads; changing the query or active +filters clears the previous search. If a search fails, the picker shows an error +and lets you retry the same query. + ### Background sessions Run long non-interactive prompts detached from the current terminal: diff --git a/docs/advanced-setup.md b/docs/advanced-setup.md index 99154a6d4..905ed9462 100644 --- a/docs/advanced-setup.md +++ b/docs/advanced-setup.md @@ -829,7 +829,13 @@ Notes: ## Provider Launch Profiles -Use profile launchers to avoid repeated environment setup: +Use profile launchers to avoid repeated environment setup. The profile defaults +to `auto` when omitted. Put launcher options (`--fast` and `--goal`) before CLI +arguments; the first CLI argument starts passthrough, so prompts and option values +are forwarded unchanged. A leading provider name such as `ollama` still selects +that profile. To use it as a prompt, pass it as a CLI argument, for example +`bun run scripts/provider-launch.ts -p ollama`, or pass a `--` separator through +to the launcher before the prompt (some Bun invocations consume that separator): ```bash # one-time profile bootstrap (prefer viable local Ollama, otherwise OpenAI) diff --git a/docs/images/resume-deep-search-error.png b/docs/images/resume-deep-search-error.png new file mode 100644 index 000000000..f0893ab6e Binary files /dev/null and b/docs/images/resume-deep-search-error.png differ diff --git a/docs/images/resume-deep-search.png b/docs/images/resume-deep-search.png new file mode 100644 index 000000000..dd81a32df Binary files /dev/null and b/docs/images/resume-deep-search.png differ diff --git a/scripts/provider-launch.test.ts b/scripts/provider-launch.test.ts index 67dd12db7..32bf76836 100644 --- a/scripts/provider-launch.test.ts +++ b/scripts/provider-launch.test.ts @@ -1,3 +1,7 @@ +import { clearManagedProfileEnv } from '../src/utils/providerProfile.ts' +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { captureSync } from "./subprocess.ts" import { describe, expect, test } from 'bun:test' import { @@ -123,3 +127,42 @@ describe('development process launches', () => { expect(result.stderr).toBe('diagnostic') }, 5000) }) + +// Exercise main and profile resolution; simulate only the external doctor/build/CLI. +test('auto-profile launches preserve CLI arguments after the passthrough boundary', () => { + const directory = mkdtempSync(join(tmpdir(), 'provider-launch-')) + try { + const childEnv = { ...process.env } + clearManagedProfileEnv(childEnv) + writeFileSync(join(directory, '.openclaude-profile.json'), JSON.stringify({ + profile: 'openai', env: { OPENAI_API_KEY: 'test-launch-key' }, + })) + const preload = join(directory, 'capture.ts') + writeFileSync(preload, `process.argv = [process.execPath, 'provider-launch.ts', ...JSON.parse(process.env.LAUNCH_ARGS)] + Bun.spawn = (argv) => { + if (argv[0] === 'node') console.log('CLI_ARGS=' + JSON.stringify(argv.slice(2))) + return { exited: Promise.resolve(0), exitCode: 0, signalCode: null } + }`) + for (const [args, expected] of [ + [['--version'], ['--version']], + [['fix tests', '--fast', 'ollama'], ['fix tests', '--fast', 'ollama']], + [['--model', 'ollama', '--goal', 'coding'], ['--model', 'ollama', '--goal', 'coding']], + [['-p', 'fix tests'], ['-p', 'fix tests']], + [['--', 'openai', '--fast'], ['openai', '--fast']], + [['openai', '--fast', '--goal', 'coding', '-p', 'fix tests'], ['-p', 'fix tests']], + ]) { + const result = captureSync(process.execPath, ['--preload', preload, + join(import.meta.dir, 'provider-launch.ts'), ...args!], { + cwd: directory, + env: { ...childEnv, OPENCLAUDE_CONFIG_DIR: directory, LAUNCH_ARGS: JSON.stringify(args) }, + timeout: 5000, + }) + expect(result.error).toBeUndefined() + expect(result.status).toBe(0) + expect(result.stdout).toContain('Launching profile: openai') + expect(result.stdout).toContain('CLI_ARGS=' + JSON.stringify(expected)) + } + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 35000) // Six children have five-second deadlines plus cleanup time. diff --git a/scripts/provider-launch.ts b/scripts/provider-launch.ts index ef0baa49e..876bfce20 100644 --- a/scripts/provider-launch.ts +++ b/scripts/provider-launch.ts @@ -59,17 +59,10 @@ function parseLaunchOptions(argv: string[]): LaunchOptions { continue } - if (arg.startsWith('--')) { - passthroughArgs.push(arg) - continue - } - - if (requestedProfile === 'auto') { - requestedProfile = null - break - } - - passthroughArgs.push(arg) + // The first CLI argument ends launcher-option parsing. In particular, + // option values and prompts may themselves look like provider names. + passthroughArgs.push(...argv.slice(arg === '--' ? i + 1 : i)) + break } return { diff --git a/src/components/LogSelector.resumeBranches.test.ts b/src/components/LogSelector.resumeBranches.test.ts index 9a9170095..2a6fb0543 100644 --- a/src/components/LogSelector.resumeBranches.test.ts +++ b/src/components/LogSelector.resumeBranches.test.ts @@ -423,3 +423,104 @@ test('rendered picker expands branch groups and selects child branch logs', asyn releaseSharedMutationLock() } }) + +test('deep search is offered only with a callback and runs from the keyboard', async () => { + await acquireSharedMutationLock('components/LogSelector.resumeBranches.test.tsx') + let renderer: Awaited> | null = null + const { stdout, stdin, getOutput } = createTestStreams() + const logs = [ + log(id(60), 'Original history', 10, { projectPath: getOriginalCwd(), tag: 'included' }), + log(id(62), 'Excluded history', 5, { projectPath: getOriginalCwd(), tag: 'other' }), + ] + const recovered = log(id(61), 'Recovered session', 20, { projectPath: getOriginalCwd() }) + const queries: string[] = [] + const selected: LogOption[] = [] + let selectionComplete: () => void = () => {} + const selection = new Promise(resolve => { selectionComplete = resolve }) + let finishSearch: (results: LogOption[]) => void = () => {} + let failSearch: (error: Error) => void = () => {} + let searchSignal: AbortSignal | undefined + let pageRequests = 0 + const onLoadMore = () => { pageRequests++ } + let deliverPage: (n: number) => Promise = async () => {} + let pageCommitted: () => void = () => {} + const onAgenticSearch = async (query: string, candidates: LogOption[], signal?: AbortSignal) => { + queries.push(query) + expect(candidates).toEqual([logs[0]]) + expect(signal?.aborted).toBe(false) + searchSignal = signal + return new Promise((resolve, reject) => { finishSearch = resolve; failSearch = reject }) + } + function Picker({ enabled }: { enabled: boolean }) { + const [loaded, setLoaded] = React.useState(logs) + React.useEffect(() => { pageCommitted() }, [loaded]) + deliverPage = n => new Promise(resolve => { + pageCommitted = resolve + setLoaded(previous => previous.concat(log(id(n), 'Paged history', n, { + projectPath: getOriginalCwd(), tag: n === 63 ? 'aaa-new-tag' : 'zzz-new-tag', + }))) + }) + return React.createElement(LogSelector, { + logs: loaded, maxHeight: 30, forceWidth: 100, + onSelect: item => { selected.push(item); selectionComplete() }, onLoadMore, + onAgenticSearch: enabled ? onAgenticSearch : undefined, + }) + } + const renderPicker = (enabled: boolean) => renderer!.render( + React.createElement(AppStateProvider, null, + React.createElement(KeybindingSetup, null, React.createElement(Picker, { enabled }))), + ) + try { + renderer = await createRoot({ + stdout: stdout as unknown as NodeJS.WriteStream, + stdin: stdin as unknown as NodeJS.ReadStream, + patchConsole: false, + }) + renderPicker(false) + await waitForFrame(getOutput, frame => frame.includes('Original history')) + stdin.write('\t') + await waitForFrame(getOutput, frame => frame.includes('Original history') && !frame.includes('Excluded history')) + stdin.write('Original') + const noCallback = await waitForFrame(getOutput, frame => frame.includes('⌕ Original')) + expect(noCallback).not.toContain('Search deeply using a background model') + renderPicker(true) + await waitForFrame(getOutput, frame => frame.includes('Search deeply using a background model')) + stdin.write('\r') + await waitForFrame(getOutput, frame => frame.includes('Type to search')) + stdin.write('\r') + await selection + expect(selected).toEqual([logs[0]]) + expect(queries).toEqual([]) + stdin.write('z') + await waitForFrame(getOutput, frame => frame.includes('⌕ z')) + stdin.write('\x1B[B') + await waitForFrame(getOutput, frame => frame.includes('❯ Search deeply using a background model')) + stdin.write('\r') + await waitForFrame(getOutput, frame => frame.includes('Searching with a background model')) + failSearch(new Error('private failure details')) + const failedFrame = await waitForFrame(getOutput, frame => frame.includes('Search failed. Please try again.')) + expect(failedFrame).not.toContain('private failure details') + expect(failedFrame).not.toContain('No matching sessions found.') + expect(failedFrame).toContain('Search deeply using a background model') + stdin.write('\r') + await waitForFrame(getOutput, frame => frame.includes('Searching with a background model')) + expect(pageRequests).toBeGreaterThan(0) + await deliverPage(63) + expect(searchSignal?.aborted).toBe(false) + finishSearch([recovered]) + await waitForFrame(getOutput, frame => frame.includes('Recovered session')) + await deliverPage(64) + const recoveredSelected = new Promise(resolve => { selectionComplete = resolve }) + stdin.write('\r') + await recoveredSelected + expect(selected.at(-1)).toEqual(recovered) + expect(queries).toEqual(['z', 'z']) + stdin.write('\t') + await waitForFrame(getOutput, frame => !frame.includes('Recovered session') && + frame.includes('Searches a limited set of loaded sessions.')) + } finally { + renderer?.unmount() + stdin.end() + releaseSharedMutationLock() + } +}) diff --git a/src/components/LogSelector.tsx b/src/components/LogSelector.tsx index 3e6e68f6e..3e15975ee 100644 --- a/src/components/LogSelector.tsx +++ b/src/components/LogSelector.tsx @@ -272,7 +272,7 @@ export function LogSelector(t0: LogSelectorProps) { t5 = $[4]; } const highlightColor = t5; - const isAgenticSearchEnabled = false; + const isAgenticSearchEnabled = typeof onAgenticSearch === "function"; const [currentBranch, setCurrentBranch] = React.useState(null); const [branchFilterEnabled, setBranchFilterEnabled] = React.useState(false); const [showAllWorktrees, setShowAllWorktrees] = React.useState(false); @@ -300,7 +300,7 @@ export function LogSelector(t0: LogSelectorProps) { const [viewMode, setViewMode] = React.useState("list"); const [previewLog, setPreviewLog] = React.useState(null); const prevFocusedIdRef = React.useRef(null); - const [selectedTagIndex, setSelectedTagIndex] = React.useState(0); + const [selectedTag, setSelectedTag] = React.useState("All"); let t8: AgenticSearchState; if ($[7] === Symbol.for("react.memo_cache_sentinel")) { t8 = { @@ -425,7 +425,7 @@ export function LogSelector(t0: LogSelectorProps) { t21 = $[23]; } const tagTabs = t21; - const effectiveTagIndex = tagTabs.length > 0 && selectedTagIndex < tagTabs.length ? selectedTagIndex : 0; + const effectiveTagIndex = Math.max(0, tagTabs.indexOf(selectedTag)); const selectedTab = tagTabs[effectiveTagIndex]; const tagFilter = selectedTab === "All" ? undefined : selectedTab; const tagTabsLines = hasTags ? 1 : 0; @@ -500,6 +500,23 @@ export function LogSelector(t0: LogSelectorProps) { filtered = t22; } const baseFilteredLogs = filtered; + const branchScope = branchFilterEnabled ? currentBranch : null; + const currentWorktreeOnly = hasMultipleWorktrees && !showAllWorktrees; + React.useEffect(() => { + agenticSearchAbortRef.current?.abort(); + setAgenticSearchState({ status: "idle" }); + }, [tagFilter, branchScope, currentWorktreeOnly, showAllProjects, searchQuery]); + const previousLogsRef = React.useRef(logs); + React.useEffect(() => { + const previousLogs = previousLogsRef.current; + previousLogsRef.current = logs; + // Progressive loading appends candidates without changing the search snapshot. + if (logs.length >= previousLogs.length && previousLogs.every((log, index) => logs[index] === log)) { + return; + } + agenticSearchAbortRef.current?.abort(); + setAgenticSearchState({ status: "idle" }); + }, [logs]); let t22; bb0: { if (!searchQuery) { @@ -834,7 +851,7 @@ export function LogSelector(t0: LogSelectorProps) { } const enterSearchMode = t34; let t35; - if ($[94] !== logs || $[95] !== onAgenticSearch || $[96] !== searchQuery) { + if ($[94] !== baseFilteredLogs || $[95] !== onAgenticSearch || $[96] !== searchQuery) { t35 = async () => { const runAgenticSearch = onAgenticSearch; if (!searchQuery.trim() || !runAgenticSearch || !isAgenticSearchEnabled) { @@ -851,7 +868,7 @@ export function LogSelector(t0: LogSelectorProps) { }); ; try { - const results_0 = await runAgenticSearch(searchQuery, logs, abortController.signal); + const results_0 = await runAgenticSearch(searchQuery, baseFilteredLogs, abortController.signal); if (abortController.signal.aborted) { return; } @@ -873,12 +890,13 @@ export function LogSelector(t0: LogSelectorProps) { status: "error", message }); + setIsAgenticSearchOptionFocused(true); logEvent("tengu_agentic_search_error", { query_length: searchQuery.length }); } }; - $[94] = logs; + $[94] = baseFilteredLogs; $[95] = onAgenticSearch; $[96] = searchQuery; $[97] = t35; @@ -890,7 +908,7 @@ export function LogSelector(t0: LogSelectorProps) { if ($[98] !== agenticResultsQuery || $[99] !== agenticSearchState.status || $[100] !== searchQuery) { t36 = () => { if (agenticSearchState.status !== "idle" && agenticSearchState.status !== "searching") { - if (agenticSearchState.status === "results" && agenticSearchState.query !== searchQuery || agenticSearchState.status === "error") { + if (agenticSearchState.status === "results" && agenticSearchState.query !== searchQuery) { setAgenticSearchState({ status: "idle" }); @@ -1097,8 +1115,8 @@ export function LogSelector(t0: LogSelectorProps) { if (input.toLowerCase() === "n" && key.ctrl) { exitSearchMode(); } else { - if (key.return || key.downArrow) { - if (searchQuery.trim() && onAgenticSearch && false && agenticSearchState.status !== "results") { + if (key.downArrow) { + if (searchQuery.trim() && onAgenticSearch && isAgenticSearchEnabled && agenticSearchState.status !== "results") { setIsAgenticSearchOptionFocused(true); } } @@ -1124,15 +1142,15 @@ export function LogSelector(t0: LogSelectorProps) { } if (hasTags && key.tab) { const offset = key.shift ? -1 : 1; - setSelectedTagIndex(prev => { - const current = prev < tagTabs.length ? prev : 0; + setSelectedTag(prev => { + const current = Math.max(0, tagTabs.indexOf(prev)); const newIndex = (current + tagTabs.length + offset) % tagTabs.length; const newTab = tagTabs[newIndex]; logEvent("tengu_session_tag_filter_changed", { is_all: newTab === "All", tag_count: uniqueTags.length }); - return newIndex; + return newTab!; }); return; } @@ -1353,7 +1371,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t66; if ($[187] !== agenticResults || $[188] !== agenticSearchState.status) { - t66 = agenticSearchState.status === "results" && agenticResults.length > 0 && Claude found these results:; + t66 = agenticSearchState.status === "results" && agenticResults.length > 0 && Matching sessions:; $[187] = agenticResults; $[188] = agenticSearchState.status; $[189] = t66; @@ -1372,7 +1390,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t68; if ($[194] !== agenticSearchState.status || $[195] !== filteredLogs) { - t68 = agenticSearchState.status === "error" && filteredLogs.length === 0 && No matching sessions found.; + t68 = agenticSearchState.status === "error" && Search failed. Please try again.; $[194] = agenticSearchState.status; $[195] = filteredLogs; $[196] = t68; @@ -1381,7 +1399,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t69; if ($[197] !== agenticSearchState.status || $[198] !== isAgenticSearchOptionFocused || $[199] !== onAgenticSearch || $[200] !== searchQuery) { - t69 = Boolean(searchQuery.trim()) && onAgenticSearch && false && agenticSearchState.status !== "searching" && agenticSearchState.status !== "results" && agenticSearchState.status !== "error" && {isAgenticSearchOptionFocused ? figures.pointer : " "}Search deeply using Claude →; + t69 = Boolean(searchQuery.trim()) && onAgenticSearch && isAgenticSearchEnabled && agenticSearchState.status !== "searching" && agenticSearchState.status !== "results" && {isAgenticSearchOptionFocused ? figures.pointer : " "}Search deeply using a background model →Searches a limited set of loaded sessions.; $[197] = agenticSearchState.status; $[198] = isAgenticSearchOptionFocused; $[199] = onAgenticSearch; @@ -1447,7 +1465,7 @@ export function LogSelector(t0: LogSelectorProps) { } let t71; if ($[222] !== agenticSearchState.status || $[223] !== currentBranch || $[224] !== exitState.keyName || $[225] !== exitState.pending || $[226] !== getExpandCollapseHint || $[227] !== hasMultipleWorktrees || $[228] !== isAgenticSearchOptionFocused || $[229] !== isSearching || $[230] !== onToggleAllProjects || $[231] !== showAllProjects || $[232] !== showAllWorktrees || $[233] !== viewMode) { - t71 = {exitState.pending ? Press {exitState.keyName} again to exit : viewMode === "rename" ? : agenticSearchState.status === "searching" ? Searching with Claude… : isAgenticSearchOptionFocused ? : viewMode === "search" ? {isSearching && false ? "Searching\u2026" : "Type to Search"} : {onToggleAllProjects && }{currentBranch && }{hasMultipleWorktrees && }Type to search{getExpandCollapseHint() && {getExpandCollapseHint()}}}; + t71 = {exitState.pending ? Press {exitState.keyName} again to exit : viewMode === "rename" ? : agenticSearchState.status === "searching" ? Searching with a background model… : isAgenticSearchOptionFocused ? : viewMode === "search" ? {isSearching && false ? "Searching\u2026" : "Type to Search"} : {onToggleAllProjects && }{currentBranch && }{hasMultipleWorktrees && }Type to search{getExpandCollapseHint() && {getExpandCollapseHint()}}}; $[222] = agenticSearchState.status; $[223] = currentBranch; $[224] = exitState.keyName; diff --git a/src/utils/agenticSessionSearch.test.ts b/src/utils/agenticSessionSearch.test.ts new file mode 100644 index 000000000..e1002f7f3 --- /dev/null +++ b/src/utils/agenticSessionSearch.test.ts @@ -0,0 +1,154 @@ +import { expect, test } from 'bun:test' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' + +test.each(['indices', 'diagnostics'] as const)('deep search validates %s at the model boundary', mode => { + // Isolate module mocks from the shared test process. + const child = Bun.spawnSync([process.execPath, '--feature=SLOW_OPERATION_LOGGING', '--eval', ` + import { mock } from 'bun:test' + import assert from 'node:assert/strict' + const debug = [], errors = [], slow = [] + process.env.CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS = '0' + let clock = 0 + Object.defineProperty(performance, 'now', { value: () => clock += 10 }) + mock.module('../bootstrap/state.js', () => ({ addSlowOperation: text => slow.push(text) })) + let responseText = 'private-response {"relevant_indices":[0]}' + let providerFails = false + let lite = false + let prompt = '' + mock.module('./debug.js', () => ({ logForDebugging: text => debug.push(text) })) + mock.module('./log.js', () => ({ getLogDisplayTitle: log => log.firstPrompt, logError: error => errors.push(String(error)) })) + mock.module('./model/model.js', () => ({ getSmallFastModel: () => 'configured-test-model' })) + mock.module('./sessionStorage.js', () => ({ isLiteLog: () => lite, loadFullLog() { throw new Error('private-transcript') } })) + mock.module('./sideQuery.js', () => ({ sideQuery: async options => { + prompt = options.messages[0].content + if (providerFails) throw new Error('private-response') + return { content: [{ type: 'text', text: responseText }] } + } })) + const { jsonParse } = await import('./slowOperations.ts') + jsonParse('{}') + assert.ok(slow.length > 0 && debug.length > 0, 'slow-operation logging is active') + debug.length = slow.length = 0 + const { agenticSessionSearch } = await import('./agenticSessionSearch.ts') + const log = { + firstPrompt: 'private-first-message', + messages: [{ type: 'user', message: { content: 'private-transcript' } }], + } + const result = await agenticSessionSearch('private-query', [log]) + assert.equal(result[0], log) + for (const text of ['private-query', 'private-first-message', 'private-transcript']) { + assert.ok(prompt.includes(text), 'search input must retain ' + text) + } + if (${JSON.stringify(mode)} === 'indices') { + for (const field of [[], [0], [0, 0.5, null, false, true, '0', '0.0', [], [0], {}, -1, 1], null, 0, '0', {}, undefined]) { + responseText = JSON.stringify({ relevant_indices: field }) + const result = await agenticSessionSearch('private-query', [log]) + assert.deepEqual(result, Array.isArray(field) && field.includes(0) ? [log] : []) + } + } else { + responseText = '{"relevant_indices":[private-transcript]}' + await assert.rejects(agenticSessionSearch('private-query', [log]), /^Error: Agentic session search failed$/) + providerFails = true + await assert.rejects(agenticSessionSearch('private-query', [log]), /^Error: Agentic session search failed$/) + providerFails = false + lite = true + responseText = '{"relevant_indices":[0]}' + assert.deepEqual(await agenticSessionSearch('private-query', [log]), [log]) + assert.ok(errors.length > 0, 'failure diagnostics remain available') + } + assert.ok(debug.length > 0, 'diagnostics remain available') + for (const text of ['private-query', 'private-first-message', 'private-transcript', 'private-response']) { + assert.ok(![...debug, ...errors, ...slow].join('\\n').includes(text), 'diagnostics exposed ' + text) + } + `], { cwd: fileURLToPath(new URL('./', import.meta.url)), timeout: 10_000 }) + expect(child.stderr.toString()).toBe('') + expect(child.exitCode).toBe(0) +}, 15_000) + +test.each([false, true])('deep search keeps real hydration diagnostics private (instrumentation: %s)', loggingEnabled => { + const directory = mkdtempSync(join(tmpdir(), 'session-search-')) + try { + const child = Bun.spawnSync([process.execPath, + ...(loggingEnabled ? ['--feature=SLOW_OPERATION_LOGGING'] : []), '--eval', String.raw` + import assert from 'node:assert/strict' + import { writeFileSync } from 'node:fs' + import { join } from 'node:path' + import { mock, spyOn } from 'bun:test' + process.env.CLAUDE_CODE_SLOW_OPERATION_THRESHOLD_MS = '0' + delete process.env.CLAUDE_CODE_DISABLE_PRECOMPACT_SKIP + let clock = 0 + Object.defineProperty(performance, 'now', { value: () => clock += 10 }) + const debug = await import('./debug.ts') + const state = await import('../bootstrap/state.ts') + const diagnostics = [] + spyOn(debug, 'logForDebugging').mockImplementation(text => diagnostics.push(String(text))) + spyOn(state, 'addSlowOperation').mockImplementation(text => diagnostics.push(String(text))) + const model = await import('./model/model.ts') + mock.module('./model/model.js', () => ({ ...model, getSmallFastModel: () => 'test-model' })) + let prompt = '' + mock.module('./sideQuery.js', () => ({ sideQuery: async options => { + prompt = options.messages[0].content + return { content: [{ type: 'text', text: '{"relevant_indices":[0]}' }] } + } })) + const { jsonParse } = await import('./slowOperations.ts') + jsonParse('{}') + const enabled = process.env.SEARCH_TEST_INSTRUMENTATION === 'true' + assert.equal(diagnostics.length > 0, enabled, 'instrumentation control') + const { agenticSessionSearch } = await import('./agenticSessionSearch.ts') + // Keep the actual storage reader and both JSONL parsing passes. + const { loadFullLog } = await import('./sessionStorage.ts') + const { SKIP_PRECOMPACT_THRESHOLD } = await import('./sessionStoragePortable.ts') + const sessionId = '38f67e50-946e-4d74-b0b6-c19759b99623' + const oldId = '00000000-0000-4000-8000-000000000001' + const boundaryId = '00000000-0000-4000-8000-000000000002' + const currentId = '00000000-0000-4000-8000-000000000003' + const user = (uuid, parentUuid, content) => ({ + type: 'user', message: { role: 'user', content }, uuid, parentUuid, + sessionId, timestamp: '2026-10-27T00:00:00.000Z', isSidechain: false, + }) + for (const compacted of [false, true]) { + const file = join(process.env.OPENCLAUDE_CONFIG_DIR, compacted ? 'large.jsonl' : 'small.jsonl') + const entries = [ + { type: 'custom-title', customTitle: 'private-title', sessionId }, + { type: 'tag', tag: 'private-tag', sessionId }, + ] + if (compacted) { + entries.push(user(oldId, null, 'discarded-history-marker' + 'x'.repeat(SKIP_PRECOMPACT_THRESHOLD))) + entries.push({ type: 'system', subtype: 'compact_boundary', uuid: boundaryId, + parentUuid: null, sessionId, timestamp: '2026-10-27T00:00:00.000Z', + content: 'Conversation compacted', compactMetadata: { trigger: 'manual', preTokens: 123 } }) + } + entries.push(user(currentId, compacted ? boundaryId : null, 'private-transcript')) + writeFileSync(file, entries.map(entry => JSON.stringify(entry)).join('\n') + '\n') + const lite = { fullPath: file, messages: [], sessionId, firstPrompt: 'public', + modified: new Date(), created: new Date(), date: '', value: 0, + messageCount: 1, isSidechain: false } + diagnostics.length = 0 + const result = await agenticSessionSearch('private-query', [lite]) + assert.equal(result.length, 1) + assert.equal(result[0].customTitle, 'private-title', 'pre-boundary title restored') + assert.equal(result[0].tag, 'private-tag', 'pre-boundary tag restored') + assert.ok(prompt.includes('private-transcript'), 'model retains transcript content') + if (compacted) assert.ok(!prompt.includes('discarded-history-marker'), 'compaction preserved') + for (const text of ['private-title', 'private-tag', 'private-transcript', 'private-query']) { + assert.ok(!diagnostics.join('\n').includes(text), 'hydration diagnostics exposed ' + text) + } + diagnostics.length = 0 + // A quiet search must not suppress diagnostics for other loader callers. + assert.deepEqual(result, [await loadFullLog(lite)]) + assert.equal(diagnostics.some(text => text.includes('private-title')), enabled, + 'ordinary loading retains its existing diagnostic behavior') + } + `], { + cwd: fileURLToPath(new URL('./', import.meta.url)), timeout: 30_000, + env: { ...process.env, OPENCLAUDE_CONFIG_DIR: directory, + SEARCH_TEST_INSTRUMENTATION: String(loggingEnabled) }, + }) + expect(child.stderr.toString()).toBe('') + expect(child.exitCode).toBe(0) + } finally { + rmSync(directory, { recursive: true, force: true }) + } +}, 35_000) diff --git a/src/utils/agenticSessionSearch.ts b/src/utils/agenticSessionSearch.ts index b54dab914..a7b409443 100644 --- a/src/utils/agenticSessionSearch.ts +++ b/src/utils/agenticSessionSearch.ts @@ -5,7 +5,6 @@ import { getLogDisplayTitle, logError } from './log.js' import { getSmallFastModel } from './model/model.js' import { isLiteLog, loadFullLog } from './sessionStorage.js' import { sideQuery } from './sideQuery.js' -import { jsonParse } from './slowOperations.js' // Limits for transcript extraction const MAX_TRANSCRIPT_CHARS = 2000 // Max chars of transcript per session @@ -47,10 +46,6 @@ Return sessions ordered by relevance (most relevant first). If truly no sessions Respond with ONLY the JSON object, no markdown formatting: {"relevant_indices": [2, 5, 0]}` -type AgenticSearchResult = { - relevant_indices: number[] -} - /** * Extracts searchable text content from a message. */ @@ -140,7 +135,7 @@ function logContainsQuery(log: LogOption, queryLower: string): boolean { } /** - * Performs an agentic search using Claude to find relevant sessions + * Performs an agentic search using the background search model to find relevant sessions * based on semantic understanding of the query. */ export async function agenticSessionSearch( @@ -176,7 +171,7 @@ export async function agenticSessionSearch( // Debug: log what data we have logForDebugging( - `Agentic search: ${logsToSearch.length}/${logs.length} logs, query="${query}", ` + + `Agentic search: ${logsToSearch.length}/${logs.length} logs, ` + `matching: ${matchingLogs.length}, with messages: ${count(logsToSearch, l => l.messages?.length > 0)}`, ) @@ -184,9 +179,9 @@ export async function agenticSessionSearch( const logsWithTranscriptsPromises = logsToSearch.map(async log => { if (isLiteLog(log)) { try { - return await loadFullLog(log) - } catch (error) { - logError(error as Error) + return await loadFullLog(log, { logParseContent: false }) + } catch { + logError(new Error('Agentic session transcript loading failed')) // If loading fails, use the lite log (no transcript) return log } @@ -252,11 +247,6 @@ Search query: "${query}" Find the sessions that are most relevant to this query.` - // Debug: log first part of the session list - logForDebugging( - `Agentic search prompt (first 500 chars): ${userMessage.slice(0, 500)}...`, - ) - try { const model = getSmallFastModel() logForDebugging(`Agentic search using model: ${model}`) @@ -276,9 +266,6 @@ Find the sessions that are most relevant to this query.` return [] } - // Debug: log the response - logForDebugging(`Agentic search response: ${textContent.text}`) - // Parse the JSON response const jsonMatch = textContent.text.match(/\{[\s\S]*\}/) if (!jsonMatch) { @@ -286,22 +273,30 @@ Find the sessions that are most relevant to this query.` return [] } - const result: AgenticSearchResult = jsonParse(jsonMatch[0]) - const relevantIndices = result.relevant_indices || [] + // The instrumented parser can log input; model output may contain private text. + const result: unknown = JSON.parse(jsonMatch[0]) + const relevantIndices = result && typeof result === 'object' && + 'relevant_indices' in result && Array.isArray(result.relevant_indices) + ? result.relevant_indices : [] // Map indices back to logs (indices are relative to logsWithTranscripts) const relevantLogs = relevantIndices - .filter(index => index >= 0 && index < logsWithTranscripts.length) - .map(index => logsWithTranscripts[index]!) + .filter((index): index is number => typeof index === 'number' && + Number.isInteger(index) && index >= 0 && index < logsWithTranscripts.length) + .flatMap(index => { + const log = logsWithTranscripts[index] + return log ? [log] : [] + }) logForDebugging( `Agentic search found ${relevantLogs.length} relevant sessions`, ) return relevantLogs - } catch (error) { - logError(error as Error) - logForDebugging(`Agentic search error: ${error}`) - return [] + } catch { + // Parser/provider errors may contain excerpts of private conversation content. + logError(new Error('Agentic session search failed')) + logForDebugging('Agentic session search failed') + throw new Error('Agentic session search failed') } } diff --git a/src/utils/promptShellExecution.test.ts b/src/utils/promptShellExecution.test.ts index b133d4ab8..afe981255 100644 --- a/src/utils/promptShellExecution.test.ts +++ b/src/utils/promptShellExecution.test.ts @@ -89,7 +89,7 @@ test('executeShellCommandsInPrompt normalizes null shell output', async () => { }) }) -test('executeShellCommandsInPrompt applies per-prefix line limits', async () => { +test.each([0, 3])('executeShellCommandsInPrompt applies a %s-line cap', async limit => { BashTool.call = (async () => ({ data: { stdout: 'line1\nline2\nline3\nline4\nline5\n', @@ -137,10 +137,10 @@ test('executeShellCommandsInPrompt applies per-prefix line limits', async () => } as never, 'bughunter', undefined, - { lineLimits: { 'git diff HEAD -- .': 3 } }, + { lineLimits: { 'git diff HEAD -- .': limit } }, ) - expect(result).toBe('line1\nline2\nline3') + expect(result).toBe(limit === 0 ? '(Bash completed with no output)' : 'line1\nline2\nline3') }) test('executeShellCommandsInPrompt does not truncate below the cap', async () => { @@ -318,3 +318,57 @@ test('granularFallback still surfaces permission denials as MalformedCommandErro ), ).rejects.toBeInstanceOf(MalformedCommandError) }) + +test.each([-1, NaN, Infinity, -Infinity, 1.5])('ignores invalid line limit %s and uses the longest valid prefix', async invalid => { + BashTool.call = (async () => ({ + data: { + stdout: 'line1\nline2\nline3\nline4\nline5\n', + stderr: '', + interrupted: false, + }, + })) as unknown as typeof BashTool.call + + BashTool.mapToolResultToToolResultBlockParam = (result, toolUseID) => + originalMapToolResultToToolResultBlockParam( + result as never, + toolUseID, + ) + + const result = await executeShellCommandsInPrompt( + '```!\ngit diff HEAD -- .\n```', + { + abortController: new AbortController(), + options: { + commands: [], + debug: false, + mainLoopModel: 'sonnet', + tools: new Map(), + verbose: false, + thinkingConfig: { type: 'disabled' }, + mcpClients: [], + mcpResources: {}, + isNonInteractiveSession: false, + agentDefinitions: { + systemDefinitions: [], + projectDefinitions: [], + userDefinitions: [], + }, + }, + readFileState: new Map(), + getAppState() { + return { + toolPermissionContext: { + ...getEmptyToolPermissionContext(), + alwaysAllowRules: { command: ['Bash(*)'] }, + }, + } + }, + setAppState() {}, + } as never, + 'bughunter', + undefined, + { lineLimits: { 'git diff': 3, 'git diff HEAD -- .': invalid } }, + ) + + expect(result).toBe('line1\nline2\nline3') +}) diff --git a/src/utils/promptShellExecution.ts b/src/utils/promptShellExecution.ts index 0bb7d4603..514aade11 100644 --- a/src/utils/promptShellExecution.ts +++ b/src/utils/promptShellExecution.ts @@ -72,7 +72,8 @@ const INLINE_PATTERN = /(?<=^|\s)!`([^`]+)`/gm * @param options.lineLimits - Map of command-prefix → max output lines. * When a snippet's executed command (trimmed) starts with one of the * prefixes, the output is sliced to that many lines before being - * substituted. Use to bound diffs or other potentially-large outputs + * substituted. Invalid limits (nonfinite, fractional, or negative) are ignored. + * Use to bound diffs or other potentially-large outputs * without widening the Bash allowlist (avoids `| head -N` in commands, * which the permission parser treats as a compound and may reject). */ @@ -256,6 +257,9 @@ function applyLineLimit( let bestPrefix = '' let bestLimit = Infinity for (const [prefix, limit] of Object.entries(limits)) { + if (!Number.isFinite(limit) || !Number.isInteger(limit) || limit < 0) { + continue + } if (trimmed === prefix || trimmed.startsWith(prefix + ' ')) { if (prefix.length > bestPrefix.length) { bestPrefix = prefix diff --git a/src/utils/sessionStorage.ts b/src/utils/sessionStorage.ts index debd900ad..8e7dc189b 100644 --- a/src/utils/sessionStorage.ts +++ b/src/utils/sessionStorage.ts @@ -3093,7 +3093,9 @@ export function stripPersistedToolUseResultsFromJSONLBuffer(buf: Buffer): Buffer function forEachParsedJSONLBufferEntry( buf: Buffer, visit: (entry: T) => void, + logParseContent = true, ): void { + const parse = logParseContent ? jsonParse : JSON.parse const bufLen = buf.length let start = 0 @@ -3110,7 +3112,7 @@ function forEachParsedJSONLBufferEntry( if (!line) continue try { - visit(jsonParse(line) as T) + visit(parse(line) as T) } catch { // Skip malformed lines } @@ -4126,8 +4128,12 @@ export function isLiteLog(log: LogOption): boolean { * Returns a new LogOption with populated messages array. * Explicit paths select the latest non-sidechain leaf and retain its session ID. * If the log is already full or loading fails, returns the original/empty log. + * Set logParseContent to false to keep transcript bytes out of slow diagnostics. */ -export async function loadFullLog(source: LogOption | string): Promise { +export async function loadFullLog( + source: LogOption | string, + opts?: { logParseContent?: boolean }, +): Promise { // Explicit file resumes use the same metadata restoration as the picker. // The leaf supplies their identity; filenames may have been renamed. const fromPath = typeof source === 'string' @@ -4177,7 +4183,7 @@ export async function loadFullLog(source: LogOption | string): Promise summaries: Map @@ -4828,7 +4834,7 @@ export async function loadTranscriptFile( } else if (entry.type === 'session-branch' && entry.sessionId) { sessionBranches.set(entry.sessionId, entry) } - }) + }, opts?.logParseContent) } // Bridge map for legacy progress entries: progress_uuid → progress_parent_uuid. @@ -4916,7 +4922,7 @@ export async function loadTranscriptFile( } else if (entry.type === 'marble-origami-snapshot') { contextCollapseSnapshot = entry } - }) + }, opts?.logParseContent) } catch { // File doesn't exist or can't be read } diff --git a/vscode-extension/openclaude-vscode/README.md b/vscode-extension/openclaude-vscode/README.md index 434c568ee..e205573e6 100644 --- a/vscode-extension/openclaude-vscode/README.md +++ b/vscode-extension/openclaude-vscode/README.md @@ -39,6 +39,11 @@ from `~/.openclaude/projects/`, including long workspace paths. When a session l CLI so both read the same history. Claude Code's `CLAUDE_CONFIG_DIR` and `~/.claude/` are separate and are not used for OpenClaude session discovery. +The chat's **New Chat** button and **OpenClaude: New Chat** command clear the +active session and displayed history. The next message starts a fresh conversation; +saved transcripts remain available through Resume Session. Switching to a saved +conversation during a response keeps Send disabled until that session starts. + ## Commands - `OpenClaude: Open Control Center` diff --git a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js index 7003783f6..b2025b829 100644 --- a/vscode-extension/openclaude-vscode/src/chat/chatProvider.js +++ b/vscode-extension/openclaude-vscode/src/chat/chatProvider.js @@ -41,6 +41,7 @@ class ChatController { this._sessionManager = sessionManager; this._process = null; this._startGeneration = 0; + this._resetGeneration = 0; this._pendingStart = null; this._webviews = new Set(); this._accumulatedText = ''; @@ -167,6 +168,14 @@ class ChatController { this._pendingPermissions.clear(); } + resetForNewSession() { + this._resetGeneration++; + this.stopSession(); + this._currentSessionId = null; + this._messages = []; + this.broadcast({ type: 'session_cleared', resetStreaming: true }); + } + async sendMessage(text) { // Reuse a pending restart so simultaneous sends retain both messages. // A stop or a new selection cancels sends waiting on the old startup. @@ -181,11 +190,14 @@ class ChatController { async _doSend(text) { if (!this._process) return; + const process = this._process; + const generation = this._startGeneration; // On first message after process start, wait for CLI to be ready. // On subsequent messages, the process is already running and accepting input. if (this._readyPromise) { const grace = new Promise(resolve => setTimeout(resolve, 8000)); await Promise.race([this._readyPromise, grace]); + if (generation !== this._startGeneration || process !== this._process) return; this._readyPromise = null; } this._accumulatedText = ''; @@ -527,14 +539,17 @@ class OpenClaudeChatViewProvider { this._chatController.abort(); break; case 'new_session': - this._chatController.stopSession(); - webview.postMessage({ type: 'session_cleared' }); + this._chatController.resetForNewSession(); break; case 'resume_session': this._chatController.stopSession(); webview.postMessage({ type: 'session_cleared' }); - await this._loadAndDisplaySession(webview, msg.sessionId); - await this._chatController.startSession({ sessionId: msg.sessionId }); + { + const generation = this._chatController._resetGeneration; + await this._loadAndDisplaySession(webview, msg.sessionId); + if (generation !== this._chatController._resetGeneration) break; + await this._chatController.startSession({ sessionId: msg.sessionId }); + } break; case 'permission_response': this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId); @@ -577,7 +592,9 @@ class OpenClaudeChatViewProvider { async _loadAndDisplaySession(webview, sessionId) { if (!this._chatController.sessionManager) return; try { + const generation = this._chatController._resetGeneration; const messages = await this._chatController.sessionManager.loadSession(sessionId); + if (generation !== this._chatController._resetGeneration) return; if (messages && messages.length > 0) { this._chatController._messages = messages; webview.postMessage({ type: 'restore_messages', messages }); @@ -636,14 +653,17 @@ class OpenClaudeChatPanelManager { this._chatController.abort(); break; case 'new_session': - this._chatController.stopSession(); - webview.postMessage({ type: 'session_cleared' }); + this._chatController.resetForNewSession(); break; case 'resume_session': this._chatController.stopSession(); webview.postMessage({ type: 'session_cleared' }); - await this._loadAndDisplaySession(webview, msg.sessionId); - await this._chatController.startSession({ sessionId: msg.sessionId }); + { + const generation = this._chatController._resetGeneration; + await this._loadAndDisplaySession(webview, msg.sessionId); + if (generation !== this._chatController._resetGeneration) break; + await this._chatController.startSession({ sessionId: msg.sessionId }); + } break; case 'permission_response': this._chatController.sendPermissionResponse(msg.requestId, msg.action, msg.toolUseId); @@ -686,7 +706,9 @@ class OpenClaudeChatPanelManager { async _loadAndDisplaySession(webview, sessionId) { if (!this._chatController.sessionManager) return; try { + const generation = this._chatController._resetGeneration; const messages = await this._chatController.sessionManager.loadSession(sessionId); + if (generation !== this._chatController._resetGeneration) return; if (messages && messages.length > 0) { this._chatController._messages = messages; webview.postMessage({ type: 'restore_messages', messages }); diff --git a/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js index df9c023a0..b270e1c82 100644 --- a/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js +++ b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.js @@ -1263,6 +1263,8 @@ function renderChatHtml({ nonce, platform }) { break; case 'session_cleared': + // Resume also clears the view before the selected session is ready. + if (msg.resetStreaming) setStreaming(false); messagesEl.innerHTML = ''; if (welcomeEl) { messagesEl.appendChild(welcomeEl); diff --git a/vscode-extension/openclaude-vscode/src/chat/chatRenderer.test.js b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.test.js new file mode 100644 index 000000000..b5f87204b --- /dev/null +++ b/vscode-extension/openclaude-vscode/src/chat/chatRenderer.test.js @@ -0,0 +1,46 @@ +const test = require('node:test'); +const assert = require('node:assert/strict'); +const vm = require('node:vm'); +const { renderChatHtml } = require('./chatRenderer'); + +test('New Chat during streaming enables sending the next prompt', () => { + // Execute the production webview script against the DOM operations used here. + const element = () => { + const classes = new Set(); + return { + style: {}, listeners: {}, value: '', scrollHeight: 20, + classList: { toggle(name, enabled) { if (enabled) classes.add(name); else classes.delete(name); } }, + addEventListener(name, listener) { this.listeners[name] = listener; }, + appendChild() {}, + }; + }; + const elements = new Map(); + const getElement = id => { + if (!elements.has(id)) elements.set(id, element()); + return elements.get(id); + }; + const sent = []; + let receive; + const html = renderChatHtml({ nonce: 'test', platform: 'linux' }); + const script = html.match(/