fix: repair provider arguments, deep search, and chat sessions (#2459)

* fix: address five AI quality findings

* fix: preserve search and session boundaries during reset

* fix: reset chat interaction and invalidate filtered search results

* fix: keep deep search diagnostics free of conversation content

* fix: preserve resume readiness and deep search result boundaries

* fix: show retryable search failures without logging model output

* fix: describe the background search model accurately

* docs: clarify deep search excerpt limits and data scope

* fix: keep search hydration diagnostics private
This commit is contained in:
JATMN
2026-10-27 15:36:29 -07:00
committed by GitHub
parent 870ea13117
commit ffa79d9cbb
21 changed files with 703 additions and 75 deletions
+13
View File
@@ -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:
+7 -1
View File
@@ -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)
Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+43
View File
@@ -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.
+4 -11
View File
@@ -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 {
@@ -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<ReturnType<typeof createRoot>> | 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<void>(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<void> = 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<LogOption[]>((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<void>(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<void>(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()
}
})
+34 -16
View File
@@ -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<string | null>(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<ViewMode>("list");
const [previewLog, setPreviewLog] = React.useState<LogOption | null>(null);
const prevFocusedIdRef = React.useRef<string | null>(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 && <Box paddingLeft={1} marginBottom={1} flexShrink={0}><Text dimColor={true} italic={true}>Claude found these results:</Text></Box>;
t66 = agenticSearchState.status === "results" && agenticResults.length > 0 && <Box paddingLeft={1} marginBottom={1} flexShrink={0}><Text dimColor={true} italic={true}>Matching sessions:</Text></Box>;
$[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 && <Box paddingLeft={1} marginBottom={1} flexShrink={0}><Text dimColor={true} italic={true}>No matching sessions found.</Text></Box>;
t68 = agenticSearchState.status === "error" && <Box paddingLeft={1} marginBottom={1} flexShrink={0}><Text dimColor={true} italic={true}>Search failed. Please try again.</Text></Box>;
$[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" && <Box flexShrink={0} flexDirection="column"><Box flexDirection="row" gap={1}><Text color={isAgenticSearchOptionFocused ? "suggestion" : undefined}>{isAgenticSearchOptionFocused ? figures.pointer : " "}</Text><Text color={isAgenticSearchOptionFocused ? "suggestion" : undefined} bold={isAgenticSearchOptionFocused}>Search deeply using Claude →</Text></Box><Box height={1} /></Box>;
t69 = Boolean(searchQuery.trim()) && onAgenticSearch && isAgenticSearchEnabled && agenticSearchState.status !== "searching" && agenticSearchState.status !== "results" && <Box flexShrink={0} flexDirection="column"><Box flexDirection="row" gap={1}><Text color={isAgenticSearchOptionFocused ? "suggestion" : undefined}>{isAgenticSearchOptionFocused ? figures.pointer : " "}</Text><Text color={isAgenticSearchOptionFocused ? "suggestion" : undefined} bold={isAgenticSearchOptionFocused}>Search deeply using a background model →</Text></Box><Text dimColor>Searches a limited set of loaded sessions.</Text><Box height={1} /></Box>;
$[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 = <Box paddingLeft={2}>{exitState.pending ? <Text dimColor={true}>Press {exitState.keyName} again to exit</Text> : viewMode === "rename" ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="save" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : agenticSearchState.status === "searching" ? <Text dimColor={true}><Byline><Text>Searching with Claude…</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : isAgenticSearchOptionFocused ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="search" /><KeyboardShortcutHint shortcut={"\u2193"} action="skip" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : viewMode === "search" ? <Text dimColor={true}><Byline><Text>{isSearching && false ? "Searching\u2026" : "Type to Search"}</Text><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="clear" /></Byline></Text> : <Text dimColor={true}><Byline>{onToggleAllProjects && <KeyboardShortcutHint shortcut="Ctrl+A" action={`show ${showAllProjects ? "current dir" : "all projects"}`} />}{currentBranch && <KeyboardShortcutHint shortcut="Ctrl+B" action="toggle branch" />}{hasMultipleWorktrees && <KeyboardShortcutHint shortcut="Ctrl+W" action={`show ${showAllWorktrees ? "current worktree" : "all worktrees"}`} />}<KeyboardShortcutHint shortcut="Ctrl+V" action="preview" /><KeyboardShortcutHint shortcut="Ctrl+R" action="rename" /><Text>Type to search</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />{getExpandCollapseHint() && <Text>{getExpandCollapseHint()}</Text>}</Byline></Text>}</Box>;
t71 = <Box paddingLeft={2}>{exitState.pending ? <Text dimColor={true}>Press {exitState.keyName} again to exit</Text> : viewMode === "rename" ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="save" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : agenticSearchState.status === "searching" ? <Text dimColor={true}><Byline><Text>Searching with a background model…</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : isAgenticSearchOptionFocused ? <Text dimColor={true}><Byline><KeyboardShortcutHint shortcut="Enter" action="search" /><KeyboardShortcutHint shortcut={"\u2193"} action="skip" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" /></Byline></Text> : viewMode === "search" ? <Text dimColor={true}><Byline><Text>{isSearching && false ? "Searching\u2026" : "Type to Search"}</Text><KeyboardShortcutHint shortcut="Enter" action="select" /><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="clear" /></Byline></Text> : <Text dimColor={true}><Byline>{onToggleAllProjects && <KeyboardShortcutHint shortcut="Ctrl+A" action={`show ${showAllProjects ? "current dir" : "all projects"}`} />}{currentBranch && <KeyboardShortcutHint shortcut="Ctrl+B" action="toggle branch" />}{hasMultipleWorktrees && <KeyboardShortcutHint shortcut="Ctrl+W" action={`show ${showAllWorktrees ? "current worktree" : "all worktrees"}`} />}<KeyboardShortcutHint shortcut="Ctrl+V" action="preview" /><KeyboardShortcutHint shortcut="Ctrl+R" action="rename" /><Text>Type to search</Text><ConfigurableShortcutHint action="confirm:no" context="Confirmation" fallback="Esc" description="cancel" />{getExpandCollapseHint() && <Text>{getExpandCollapseHint()}</Text>}</Byline></Text>}</Box>;
$[222] = agenticSearchState.status;
$[223] = currentBranch;
$[224] = exitState.keyName;
+154
View File
@@ -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)
+21 -26
View File
@@ -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')
}
}
+57 -3
View File
@@ -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')
})
+5 -1
View File
@@ -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
+12 -6
View File
@@ -3093,7 +3093,9 @@ export function stripPersistedToolUseResultsFromJSONLBuffer(buf: Buffer): Buffer
function forEachParsedJSONLBufferEntry<T>(
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<T>(
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<LogOption> {
export async function loadFullLog(
source: LogOption | string,
opts?: { logParseContent?: boolean },
): Promise<LogOption> {
// 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<LogOption
contextCollapseCommits,
contextCollapseSnapshot,
leafUuids,
} = await loadTranscriptFile(sessionFile)
} = await loadTranscriptFile(sessionFile, opts)
if (messages.size === 0) {
return log
@@ -4679,7 +4685,7 @@ function walkChainBeforeParse(buf: Buffer): Buffer {
*/
export async function loadTranscriptFile(
filePath: string,
opts?: { keepAllLeaves?: boolean },
opts?: { keepAllLeaves?: boolean; logParseContent?: boolean },
): Promise<{
messages: Map<UUID, TranscriptMessage>
summaries: Map<UUID, string>
@@ -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
}
@@ -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`
@@ -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 });
@@ -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);
@@ -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(/<script nonce="test">([\s\S]*?)<\/script>/)[1];
vm.runInNewContext(script, {
document: { getElementById: getElement, createElement: element, addEventListener() {} },
window: { addEventListener(_name, listener) { receive = listener; } },
acquireVsCodeApi: () => ({ postMessage: message => sent.push(message), getState: () => null }),
requestAnimationFrame: callback => callback(),
});
getElement('chatInput').value = 'old prompt';
getElement('sendBtn').listeners.click();
assert.equal(getElement('sendBtn').disabled, true);
getElement('newChatBtn').listeners.click();
assert.equal(sent.at(-1).type, 'new_session');
receive({ data: { type: 'session_cleared', resetStreaming: true } });
assert.equal(getElement('sendBtn').disabled, false);
assert.equal(getElement('abortBtn').style.display, 'none');
assert.equal(getElement('statusText').textContent, 'Ready');
getElement('chatInput').value = 'fresh prompt';
getElement('sendBtn').listeners.click();
assert.equal(sent.at(-1).type, 'send_message');
assert.equal(sent.at(-1).text, 'fresh prompt');
assert.equal(getElement('sendBtn').disabled, true);
});
@@ -50,8 +50,10 @@ test('resume hands the selected transcript to the CLI, including reconnect and W
return originalLoad.call(this, name, ...rest);
};
const { SessionManager } = require('./sessionManager');
const { ChatController } = require('./chatProvider');
const { ChatController, OpenClaudeChatViewProvider, OpenClaudeChatPanelManager } = require('./chatProvider');
const { ProcessManager } = require('./processManager');
const { renderChatHtml } = require('./chatRenderer');
const vm = require('node:vm');
(async () => {
const root = await fs.mkdtemp(path.join(os.tmpdir(), 'vscode-resume-'));
const controller = new ChatController(new SessionManager());
@@ -83,6 +85,149 @@ test('resume hands the selected transcript to the CLI, including reconnect and W
assert.equal(controller.sessionId, id);
controller.stopSession();
// Both UI entry points must reset identity/history, not reconnect it.
const fresh = new ChatController(manager);
try {
for (const Provider of [OpenClaudeChatViewProvider, OpenClaudeChatPanelManager]) {
await fresh.startSession({ sessionId: id });
children.at(-1).stdout.write(JSON.stringify({ type: 'system', session_id: id }) + '\n');
let receive;
const updates = [];
const elements = new Map();
const element = () => ({ style: {}, value: '', scrollHeight: 20, listeners: {},
classList: { toggle() {} }, appendChild() {},
addEventListener(name, listener) { this.listeners[name] = listener; } });
const getElement = name => {
if (!elements.has(name)) elements.set(name, element());
return elements.get(name);
};
let receiveInRenderer;
let lastAction;
const rendererSends = [];
const webview = {
onDidReceiveMessage: handler => { receive = handler; },
postMessage: message => {
updates.push(message);
receiveInRenderer?.({ data: message });
},
};
const html = renderChatHtml({ nonce: 'test', platform: 'linux' });
vm.runInNewContext(html.match(/<script nonce="test">([\s\S]*?)<\/script>/)[1], {
document: { getElementById: getElement, createElement: element, addEventListener() {} },
window: { addEventListener(_name, listener) { receiveInRenderer = listener; } },
acquireVsCodeApi: () => ({ getState: () => null, postMessage: message => {
if (message.type === 'send_message') rendererSends.push(message.text);
lastAction = receive?.(message);
} }),
requestAnimationFrame: callback => callback(),
});
const submitPrompt = text => {
getElement('chatInput').value = text;
getElement('sendBtn').listeners.click();
return lastAction;
};
const otherUpdates = [];
const registrations = [fresh.registerWebview(webview),
fresh.registerWebview({ postMessage: message => otherUpdates.push(message) })];
new Provider(fresh)._attachMessageHandler(webview);
const oldChild = children.at(-1);
const oldDelivery = new Promise(resolve => oldChild.stdin.once('data', resolve));
await submitPrompt('old prompt');
await oldDelivery;
const loadForResume = manager.loadSession;
let releaseResume;
manager.loadSession = () => new Promise(resolve => { releaseResume = resolve; });
try {
const selectedId = '48f67e50-946e-4d74-b0b6-c19759b99623';
const loading = receive({ type: 'resume_session', sessionId: selectedId });
const launchCount = calls.length;
const sendCount = rendererSends.length;
await submitPrompt('must not reach the previous session');
assert.equal(rendererSends.length, sendCount);
assert.equal(calls.length, launchCount);
assert.equal(oldChild.stdin.read(), null);
releaseResume([{ role: 'user', text: 'selected transcript' }]);
await loading;
assert.equal(fresh.sessionId, selectedId);
assert.equal(resumeValue(), selectedId);
const selectedChild = children.at(-1);
selectedChild.stdout.write(JSON.stringify({ type: 'system', session_id: selectedId }) + '\n');
const selectedDelivery = new Promise(resolve => selectedChild.stdin.once('data', resolve));
await submitPrompt('continue selected session');
assert.ok((await selectedDelivery).toString().includes('continue selected session'));
} finally {
manager.loadSession = loadForResume;
}
await receive({ type: 'new_session' });
assert.equal(fresh.sessionId, null);
assert.deepEqual(fresh.getMessages(), []);
assert.ok(updates.some(message => message.type === 'session_cleared' && message.resetStreaming));
assert.ok(otherUpdates.some(message => message.type === 'session_cleared' && message.resetStreaming));
updates.length = 0;
await receive({ type: 'restore_request' });
assert.ok(!updates.some(message => message.type === 'restore_messages'));
const next = submitPrompt('fresh prompt');
const child = children.at(-1);
const freshDelivery = new Promise(resolve => child.stdin.once('data', resolve));
assert.ok(!calls.at(-1).args.includes('--resume'));
child.stdout.write(JSON.stringify({ type: 'system', session_id: 'new-id' }) + '\n');
await next;
const deliveredPrompt = await freshDelivery;
assert.equal(fresh.sessionId, 'new-id');
assert.deepEqual(fresh.getMessages(), [{ role: 'user', text: 'fresh prompt' }]);
assert.ok(deliveredPrompt.toString().includes('fresh prompt'));
fresh.stopSession();
const loadSession = manager.loadSession;
let finishLoad;
manager.loadSession = () => new Promise(resolve => { finishLoad = resolve; });
try {
const resume = receive({ type: 'resume_session', sessionId: id });
await receive({ type: 'new_session' });
const count = calls.length;
finishLoad([{ role: 'user', text: 'late old history' }]);
await resume;
assert.equal(calls.length, count);
assert.equal(fresh.sessionId, null);
assert.deepEqual(fresh.getMessages(), []);
assert.ok(!updates.some(message => message.type === 'restore_messages'));
// An ordinary send must not cancel the selected history load.
const ordinaryResume = receive({ type: 'resume_session', sessionId: id });
const ordinarySend = fresh.sendMessage('sent while loading');
await new Promise(setImmediate);
children.at(-1).stdout.write(JSON.stringify({ type: 'system', session_id: 'interim-id' }) + '\n');
await ordinarySend;
finishLoad([{ role: 'user', text: 'selected history' }]);
await ordinaryResume;
assert.equal(fresh.sessionId, id);
assert.deepEqual(fresh.getMessages(), [{ role: 'user', text: 'selected history' }]);
assert.equal(resumeValue(), file);
fresh.stopSession();
} finally {
manager.loadSession = loadSession;
registrations.forEach(registration => registration.dispose());
}
}
// An old send waiting for CLI readiness must not enter the new chat.
await fresh.startSession({ sessionId: id });
const releaseOldReady = fresh._readyResolve;
const oldSend = fresh.sendMessage('stale pending prompt');
await new Promise(setImmediate);
fresh.resetForNewSession();
const newSend = fresh.sendMessage('new pending prompt');
await new Promise(setImmediate);
const newReady = fresh._readyPromise;
releaseOldReady();
await oldSend;
assert.equal(fresh._readyPromise, newReady);
assert.deepEqual(fresh.getMessages(), []);
children.at(-1).stdout.write(JSON.stringify({ type: 'system', session_id: 'fresh-id' }) + '\n');
await newSend;
assert.deepEqual(fresh.getMessages(), [{ role: 'user', text: 'new pending prompt' }]);
} finally {
fresh.stopSession();
}
// A stop during the new asynchronous lookup must not spawn an orphan.
let finish;
manager.getResumeTarget = () => new Promise(resolve => { finish = resolve; });
@@ -212,6 +212,11 @@ class SessionManager {
/** Resolve the same transcript used by the history view and CLI resume. */
async resolveSessionPath(sessionId) {
// History IDs name a single transcript, never a relative or absolute path.
if (typeof sessionId !== 'string' || !sessionId || /[/\\\0]/.test(sessionId)) {
return null;
}
const cwd = this._cwd;
const dirs = cwd ? await getProjectDirs(cwd) : await this._allProjectDirs();
@@ -137,3 +137,20 @@ test('a zero-byte exact transcript does not hide a populated alternate history',
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'Recovered history' }]);
assert.equal(await manager.getResumeTarget(sessionId), alternatePath);
});
test('session lookup rejects path components before reading outside project history', async () => {
const configDir = resolveClaudeConfigHomeDir({ homeDir: home });
const cwd = '/workspace/demo';
await writeSession(configDir, cwd, 'Valid history');
await fs.writeFile(path.join(configDir, 'projects', 'outside.jsonl'),
JSON.stringify({ type: 'user', message: { content: 'Outside history' } }) + '\n');
const manager = new SessionManager();
for (const workspace of [cwd, null]) {
manager.setCwd(workspace);
for (const invalid of ['../outside', '..\\outside', 'nested/../../outside', '', null, 42]) {
assert.equal(await manager.resolveSessionPath(invalid), null);
assert.equal(await manager.loadSession(invalid), null);
}
assert.deepEqual(await manager.loadSession(sessionId), [{ role: 'user', text: 'Valid history' }]);
}
});
@@ -1311,8 +1311,7 @@ function activate(context) {
// ── New chat commands ──
const newChatCommand = vscode.commands.registerCommand('openclaude.newChat', () => {
chatController.stopSession();
chatController.broadcast({ type: 'session_cleared' });
chatController.resetForNewSession();
});
const openChatCommand = vscode.commands.registerCommand('openclaude.openChat', () => {