mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-11-06 12:50:54 -04:00
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:
@@ -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:
|
||||
|
||||
@@ -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 |
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
@@ -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')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
Reference in New Issue
Block a user