diff --git a/mobile/src/agent-history/agent-history-association.test.tsx b/mobile/src/agent-history/agent-history-association.test.tsx index c49b5f27868..c3638ade5bc 100644 --- a/mobile/src/agent-history/agent-history-association.test.tsx +++ b/mobile/src/agent-history/agent-history-association.test.tsx @@ -6,6 +6,8 @@ import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState, RpcResponse } from '../transport/types' import type { Worktree } from '../worktree/workspace-list-types' import { useMobileAgentHistoryState } from './use-mobile-agent-history-state' +import { resolveMobileAiVaultSessionResumeTarget } from './agent-history-resume-target' +import { buildMobileAiVaultResumeLaunch } from '../session/ai-vault-resume-launch' const connection = vi.hoisted((): { client: RpcClient | null; state: ConnectionState } => ({ client: null, @@ -48,7 +50,8 @@ it('refreshes a mounted provider row on current-client association changes with executionHostId: 'local', filePath: '/provider', resumeCommand: 'pi --session /provider', - cwd: null, + cwd: '/private/var/example', + cwdAliases: ['/var/example'], modifiedAt: '', previewMessages: [], messageCount: 1, @@ -84,7 +87,21 @@ it('refreshes a mounted provider row on current-client association changes with let clientGeneration = 0 connection.client = { ...fakeClient(send), subscribe, getGeneration: () => clientGeneration } connection.state = 'connected' - const worktrees: Worktree[] = [] + const worktrees: Worktree[] = [ + { + worktreeId: 'workspace', + repoId: 'repo', + repo: 'repo', + branch: 'main', + displayName: 'example', + path: '/var/example', + liveTerminalCount: 0, + hasAttachedPty: false, + unread: false, + isPinned: false, + linkedPR: null + } + ] let hostId = 'phone-host' let current: ReturnType | null = null function Probe() { @@ -105,6 +122,70 @@ it('refreshes a mounted provider row on current-client association changes with return current } try { + expect(send).toHaveBeenCalledWith( + 'aiVault.listSessions', + expect.objectContaining({ scopePaths: ['/var/example'] }) + ) + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [{ cwdAliases: ['/var/example'] }] + }) + await act(async () => read().onSelectScope('all')) + expect(send).toHaveBeenLastCalledWith( + 'aiVault.listSessions', + expect.objectContaining({ scopePaths: ['/var/example'] }) + ) + expect(read().screenState).toMatchObject({ + kind: 'ready', + sessions: [{ cwdAliases: ['/var/example'] }] + }) + const ready = read().screenState + if (ready.kind !== 'ready') { + throw new Error('history did not become ready') + } + const delivered = ready.sessions[0] + const targetArgs = { + session: delivered, + activeWorktreeId: 'elsewhere', + worktrees: [ + { ...worktrees[0], worktreeId: 'elsewhere', path: '/elsewhere' }, + { + ...worktrees[0], + worktreeId: 'folder:example', + repoId: 'folder-workspace:group', + workspaceKind: 'folder-workspace' as const + } + ], + catalog: { + repos: [], + folderWorkspaces: [{ id: 'example', projectGroupId: 'group', folderPath: '/var/example' }], + projectGroups: [] + }, + hostPlatform: 'darwin' as const + } + expect(resolveMobileAiVaultSessionResumeTarget(targetArgs)).toMatchObject({ + status: 'ready', + worktreeId: 'folder:example', + workspacePath: '/var/example' + }) + expect( + resolveMobileAiVaultSessionResumeTarget({ + ...targetArgs, + session: { ...delivered, executionHostId: 'ssh:other' } + }).status + ).toBe('blocked') + const launch = buildMobileAiVaultResumeLaunch({ + session: delivered, + workspacePlatform: 'darwin' + }) + expect(launch.command).toContain("cd '/private/var/example'") + expect(launch.resumeProviderSession).toEqual({ + key: 'session_id', + id: row.sessionId, + transcriptPath: row.filePath + }) + expect(delivered.cwd).toBe(row.cwd) + expect(delivered.sessionId).toBe(row.sessionId) await act(async () => emit({ type: 'snapshot', sessions: [native] })) const baseline = send.mock.calls.length for (const n of [10, 100]) { diff --git a/mobile/src/agent-history/agent-history-resume-target.test.ts b/mobile/src/agent-history/agent-history-resume-target.test.ts index 70dd9061f4e..7a37afd98c3 100644 --- a/mobile/src/agent-history/agent-history-resume-target.test.ts +++ b/mobile/src/agent-history/agent-history-resume-target.test.ts @@ -68,6 +68,30 @@ function resolve( } describe('mobile AI Vault resume target guards', () => { + it('uses host correspondence to select the owning folder instead of the active route', () => { + const folder = worktree({ + worktreeId: 'folder:linked', + repoId: 'folder-workspace:group', + workspaceKind: 'folder-workspace', + path: '/var/example' + }) + const args = { + session: session({ cwd: '/private/var/example', cwdAliases: ['/var/example'] }), + activeWorktreeId: 'route', + worktrees: [worktree({ worktreeId: 'route', path: '/elsewhere' }), folder], + folderWorkspaces: [{ id: 'linked', projectGroupId: 'group', folderPath: '/var/example' }], + projectGroups: [{ id: 'group', parentGroupId: null, connectionId: null }] + } + expect(resolve(args)).toMatchObject({ + status: 'ready', + worktreeId: 'folder:linked', + workspacePath: '/var/example' + }) + expect( + resolve({ ...args, session: { ...args.session, executionHostId: 'ssh:other' } }).status + ).toBe('blocked') + }) + const repos = [ { id: 'local-repo', path: '/Users/ada/repo', connectionId: null }, { id: 'ssh-repo', path: '/home/ada/ssh-repo', connectionId: 'builder' }, diff --git a/src/main/native-chat/agent-session-journal/adopted-directory-probe.test.ts b/src/main/native-chat/agent-session-journal/adopted-directory-probe.test.ts new file mode 100644 index 00000000000..8d7a2151a39 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/adopted-directory-probe.test.ts @@ -0,0 +1,145 @@ +import { runInNewContext } from 'node:vm' +import { posix, win32 } from 'node:path' +import { Readable } from 'node:stream' +import { setImmediate } from 'node:timers/promises' +import { expect, it, vi } from 'vitest' +import { ADOPTED_DIRECTORY_PROBE_SOURCE } from './adopted-directory-probe' +import { normalizeRuntimePathForComparison } from '../../../shared/cross-platform-path' + +async function helper( + workspaceCwd: string, + sourceCwd: string, + paths: Record, + windows = false, + splitOffsets: readonly number[] = [] +) { + const reads: string[] = [] + let output = '' + const process = { + stdin: Readable.from( + (async function* () { + const bytes = Buffer.from(JSON.stringify({ version: 1, workspaceCwd, sourceCwd })) + let start = 0 + for (const end of [...splitOffsets, bytes.length]) { + yield bytes.subarray(start, end) + await setImmediate() + start = end + } + })(), + { objectMode: false } + ), + stdout: { + write: (text: string) => { + output += text + } + }, + exitCode: 0 + } + await runInNewContext(ADOPTED_DIRECTORY_PROBE_SOURCE, { + process, + require: (name: string) => + name === 'node:path' + ? windows + ? win32 + : posix + : { + realpath: vi.fn(async (path: string) => { + reads.push(path) + if (!(path in paths)) { + throw new Error('unavailable') + } + return paths[path] + }) + } + }) + return { reads, output, code: process.exitCode } +} + +it('visits only selected native prefixes, stopping at a match or exhaustion', async () => { + const result = await helper('/link/pkg', '/physical/pkg', { + '/link/pkg': '/elsewhere', + '/link': '/physical', + '/': '/' + }) + expect(result).toEqual({ + code: 0, + output: '{"version":1,"matched":true}', + reads: ['/link/pkg', '/link'] + }) + const noMatch = await helper('/link/pkg', '/foreign', { + '/link/pkg': '/elsewhere', + '/link': '/physical', + '/': '/' + }) + expect(noMatch.reads).toEqual(['/link/pkg', '/link', '/']) + expect(noMatch.output).toBe('{"version":1,"matched":false}') + const unavailable = await helper('/missing/pkg', '/foreign', { '/': '/' }) + expect(unavailable.code).toBe(1) + expect(unavailable.output).toBe('') + const posixBackslash = await helper('/link/pkg', '/physical\\/pkg', { + '/link/pkg': '/elsewhere', + '/link': '/physical\\' + }) + expect(posixBackslash.output).toBe('{"version":1,"matched":true}') + expect(posixBackslash.reads).toEqual(['/link/pkg', '/link']) +}) + +it('keeps native drive/UNC, NFC and POSIX comparison semantics, without source dot collapsing', async () => { + for (const [workspace, resolved, source, windows] of [ + ['C:\\link\\pkg', 'D:\\Physical\\pkg', 'd:/physical/pkg/', true], + ['\\\\host\\share\\link', '\\\\host\\share\\Physical', '//HOST/share/physical', true], + ['C:\\link', '\\\\wsl.localhost\\Ubuntu\\Repo', '//wsl$/ubuntu/Repo', true], + ['C:\\link', '\\\\wsl.localhost\\Ubuntu\\Repo', '//wsl$/ubuntu/repo', true], + ['/link', '/café', '/cafe\u0301', false], + ['/link', '/Physical', '/physical', false], + ['/link', '/physical', '/other/../physical', false] + ] as const) { + const path = windows ? win32 : posix + const paths: Record = { [workspace]: resolved } + for (let parent = path.dirname(workspace); !(parent in paths); parent = path.dirname(parent)) { + paths[parent] = parent + if (path.dirname(parent) === parent) { + break + } + } + const result = await helper(workspace, source, paths, windows) + expect(result.code).toBe(0) + expect(JSON.parse(result.output).matched).toBe( + normalizeRuntimePathForComparison(resolved) === normalizeRuntimePathForComparison(source) + ) + } + const wsl = await helper('\\\\wsl.localhost\\Ubuntu\\repo', '/repo', {}, true) + expect(wsl.code).toBe(1) + expect(wsl.reads).toEqual([]) +}) + +it.each(['é', '界', '😀'])( + 'preserves %s in W and C across byte delivery schedules', + async (character) => { + const parent = `/selected-${character}` + const workspaceCwd = `${parent}/pkg` + const physical = `/physical-${character}` + const paths = { + [workspaceCwd]: '/elsewhere', + [parent]: physical.normalize('NFD'), + '/': '/' + } + for (const matched of [true, false]) { + const sourceCwd = matched ? `${physical}/pkg` : `/foreign-${character}/pkg` + const bytes = Buffer.from(JSON.stringify({ version: 1, workspaceCwd, sourceCwd })) + const expected = { + code: 0, + output: JSON.stringify({ version: 1, matched }), + reads: matched ? [workspaceCwd, parent] : [workspaceCwd, parent, '/'] + } + expect(await helper(workspaceCwd, sourceCwd, paths)).toEqual(expected) + for (let offset = 1; offset < Buffer.byteLength(character); offset++) { + const w = bytes.indexOf(character) + offset + const c = bytes.lastIndexOf(character) + offset + for (const splits of [[w], [c], [w, c]]) { + expect(await helper(workspaceCwd, sourceCwd, paths, false, splits)).toEqual(expected) + } + } + } + } +) diff --git a/src/main/native-chat/agent-session-journal/adopted-directory-probe.ts b/src/main/native-chat/agent-session-journal/adopted-directory-probe.ts new file mode 100644 index 00000000000..a79763d3451 --- /dev/null +++ b/src/main/native-chat/agent-session-journal/adopted-directory-probe.ts @@ -0,0 +1,104 @@ +import { dirname } from 'node:path' +import { runProcess } from '@orca/process-host' +import { pickAllowedEnv, RUNTIME_ENV_ALLOWLIST } from '../../ai-vault/session-scanner-service-env' + +export const ADOPTED_DIRECTORY_PROBE_TIMEOUT_MS = 5_000 +export const ADOPTED_DIRECTORY_PROBE_MAX_OUTPUT_BYTES = 4_096 + +// Static source travels in both host bundles; paths stay stdin data, never executable text. +export const ADOPTED_DIRECTORY_PROBE_SOURCE = String.raw` +const {realpath} = require('node:fs/promises'); +const {isAbsolute, dirname, sep} = require('node:path'); +// Match normalizeRuntimePathForComparison; never resolve or normalize source dot segments. +function comparison(raw) { + const value = raw.normalize('NFC'); + const windows = /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//'); + let path = windows ? value.replace(/\\/g, '/') : value; + path = path.replace(/\/+/g, '/'); + if (windows && (value.startsWith('\\\\') || value.startsWith('//'))) path = '/' + path; + if (path !== '/' && !/^[A-Za-z]:\/$/.test(path)) path = path.replace(/\/+$/, ''); + const wsl = path.match(/^\/\/(?:wsl\.localhost|wsl\$)\/([^/]+)(\/[\s\S]*)?$/i); + if (wsl) return '//wsl/' + wsl[1].toLowerCase() + (wsl[2] || ''); + return windows ? path.toLowerCase() : path; +} +async function main() { + process.stdin.setEncoding('utf8'); + let input = ''; + for await (const chunk of process.stdin) input += chunk; + const request = JSON.parse(input); + if (request.version !== 1 || typeof request.workspaceCwd !== 'string' || + typeof request.sourceCwd !== 'string' || !isAbsolute(request.workspaceCwd) || + /^[/\\]{2}wsl(?:\.localhost|\$)[/\\]/i.test(request.workspaceCwd)) throw Error('invalid request'); + const workspace = request.workspaceCwd; + const source = comparison(request.sourceCwd.trim()); + let prefix = workspace; + let unavailable = false; + for (;;) { + const resolved = await realpath(prefix).catch(() => { unavailable = true; return null; }); + const suffix = workspace.slice(prefix.length); + const candidate = resolved && (suffix + ? (resolved.endsWith(sep) ? resolved.slice(0, -1) : resolved) + + (suffix.startsWith(sep) || (sep === '\\' && suffix.startsWith('/')) ? '' : sep) + suffix + : resolved); + if (candidate && comparison(candidate) === source) { + process.stdout.write(JSON.stringify({version:1, matched:true})); + return; + } + const parent = dirname(prefix); + if (parent === prefix) break; + prefix = parent; + } + if (unavailable) throw Error('unavailable directory verification'); + process.stdout.write(JSON.stringify({version:1, matched:false})); +} +main().catch(() => { process.exitCode = 1; }); +` + +/** Logical requests finish independently; process-host retains each child until its close callback. */ +export class AdoptedDirectoryProbes { + private readonly owned = new Set() + + get ownedCount(): number { + return this.owned.size + } + + async matches(workspaceCwd: string, sourceCwd: string, signal?: AbortSignal): Promise { + const job = {} + this.owned.add(job) + const env = pickAllowedEnv(RUNTIME_ENV_ALLOWLIST, process.env, process.platform) + env.ELECTRON_RUN_AS_NODE = '1' + try { + const result = await runProcess({ + program: process.execPath, + args: ['-e', ADOPTED_DIRECTORY_PROBE_SOURCE], + cwd: dirname(process.execPath), + env, + input: JSON.stringify({ version: 1, workspaceCwd, sourceCwd }), + signal, + timeoutMs: ADOPTED_DIRECTORY_PROBE_TIMEOUT_MS, + maxOutputBytes: ADOPTED_DIRECTORY_PROBE_MAX_OUTPUT_BYTES, + killOnOutputLimit: true, + onChildTerminated: () => this.owned.delete(job) + }) + if (signal?.aborted || result.timedOut || result.code !== 0 || result.outputTruncated) { + throw new Error('The conversation directory could not be verified. Try Resume again.') + } + const value: unknown = JSON.parse(result.stdout) + if ( + !value || + typeof value !== 'object' || + !('version' in value) || + value.version !== 1 || + !('matched' in value) || + typeof value.matched !== 'boolean' + ) { + throw new Error('Invalid directory verification response') + } + return value.matched + } catch { + throw new Error('The conversation directory could not be verified. Try Resume again.') + } + } +} + +export const adoptedDirectoryProbes = new AdoptedDirectoryProbes() diff --git a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts index 433a9b56431..bd80e4d520e 100644 --- a/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts +++ b/src/main/native-chat/agent-session-journal/adopted-transcript-source.ts @@ -7,23 +7,37 @@ import { asRecord, extractString, parseJsonObject } from '../../ai-vault/session import { readOpenCodeTranscriptSignalViaWorker } from '../../ai-vault/session-scanner-opencode-sqlite-worker-spawn' import { samePiDirectory } from '../../pi/rpc-launch-resolution' import { admitTranscriptPath } from '../transcript-path-admission' +import { adoptedDirectoryProbes } from './adopted-directory-probe' export type AdoptedTranscriptSource = { providerSessionId: string; workspaceCwd: string } -export function assertAdoptedSourceIdentity( +export async function assertAdoptedSourceIdentity( source: AdoptedTranscriptSource, actual: { sessionId: string | null; cwd: string | null }, - requireCwd = true -): void { + requireCwd = true, + signal?: AbortSignal, + agent?: AgentType +): Promise { if (actual.sessionId !== source.providerSessionId) { throw new Error('The saved conversation does not match the requested provider identity.') } + if (!requireCwd) { + return + } + if (!actual.cwd) { + throw new Error('This conversation belongs to another directory.') + } if ( - requireCwd && - (!actual.cwd || - normalizeRuntimePathForComparison(actual.cwd.trim()) !== - normalizeRuntimePathForComparison(source.workspaceCwd.trim())) + normalizeRuntimePathForComparison(actual.cwd.trim()) === + normalizeRuntimePathForComparison(source.workspaceCwd.trim()) ) { + return + } + const matched = + agent === 'pi' + ? await samePiDirectory(actual.cwd, source.workspaceCwd) + : await adoptedDirectoryProbes.matches(source.workspaceCwd, actual.cwd, signal) + if (!matched) { throw new Error('This conversation belongs to another directory.') } } @@ -34,6 +48,7 @@ export function adoptedTranscriptSourceObserver(input: { filePath: string source: AdoptedTranscriptSource maxSourceBytes: number + signal?: AbortSignal }) { let sessionId: string | null = input.agent === 'claude' || input.agent === 'codex' @@ -75,24 +90,36 @@ export function adoptedTranscriptSourceObserver(input: { if (input.agent === 'pi' && sessionIdFromFileName(input.filePath) !== sessionId) { throw new Error('The saved conversation header does not match its file identity.') } - if (input.agent === 'pi' && cwd && (await samePiDirectory(cwd, input.source.workspaceCwd))) { - cwd = input.source.workspaceCwd - } - assertAdoptedSourceIdentity(input.source, { sessionId, cwd }, input.agent !== 'codex') + await assertAdoptedSourceIdentity( + input.source, + { sessionId, cwd }, + input.agent !== 'codex', + input.signal, + input.agent + ) } } } export async function validateAdoptedOpenCodeSource( dbPath: string, - source: AdoptedTranscriptSource + source: AdoptedTranscriptSource, + signal?: AbortSignal ): Promise { - const signal = await readOpenCodeTranscriptSignalViaWorker({ - dbPath, - sessionId: source.providerSessionId - }) - assertAdoptedSourceIdentity(source, { - sessionId: signal ? source.providerSessionId : null, - cwd: signal?.cwd ?? null - }) + const metadata = await readOpenCodeTranscriptSignalViaWorker( + { + dbPath, + sessionId: source.providerSessionId + }, + signal + ) + await assertAdoptedSourceIdentity( + source, + { + sessionId: metadata ? source.providerSessionId : null, + cwd: metadata?.cwd ?? null + }, + true, + signal + ) } diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts index 4d60a38bc03..fb87b620ab3 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.test.ts @@ -491,6 +491,7 @@ describe('import failures', () => { type: 'user', uuid: `${index}-${longId}`, sessionId: CLAUDE_SESSION, + cwd: index === 0 ? '/foreign-source' : root, message: { role: 'user', content: 'small' } }) ) @@ -513,6 +514,14 @@ describe('import failures', () => { provider: 'claude', uuid: `249-${longId}` }) + // Pruning away the first row cannot erase its authoritative source cwd. + expect( + await prepareLegacyTranscriptImport({ + agent: 'claude', + sessionId: CLAUDE_SESSION, + options: { filePath, source: { providerSessionId: CLAUDE_SESSION, workspaceCwd: root } } + }) + ).toMatchObject({ ok: false, error: 'This conversation belongs to another directory.' }) }) it('imports a source past 16 MiB, keeping its newest messages', async () => { diff --git a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts index de21b768984..23df06f5e1c 100644 --- a/src/main/native-chat/agent-session-journal/journal-legacy-import.ts +++ b/src/main/native-chat/agent-session-journal/journal-legacy-import.ts @@ -42,6 +42,7 @@ export type LegacyImportOptions = ResolveSessionFileOptions & { source?: AdoptedTranscriptSource limits?: JournalPayloadLimits decodedMessageIdentities?: true + signal?: AbortSignal } const MAX_LEGACY_IMPORT_RECORD_BYTES = 16 * 1024 * 1024 @@ -111,6 +112,7 @@ export async function prepareLegacyTranscriptImport(input: { agent: input.agent, sessionId: input.sessionId, source: options.source, + signal: options.signal, decodedMessageIdentities: options.decodedMessageIdentities, limits }) @@ -133,7 +135,7 @@ async function prepareOpenCodeImport(input: { return { ok: false, error: 'The conversation database is unavailable.' } } try { - await validateAdoptedOpenCodeSource(dbPath, input.options.source) + await validateAdoptedOpenCodeSource(dbPath, input.options.source, input.options.signal) } catch (error) { return { ok: false, error: error instanceof Error ? error.message : String(error) } } @@ -199,6 +201,7 @@ async function decodeReplacementItems(input: { sessionId: string decodedMessageIdentities?: true source?: AdoptedTranscriptSource + signal?: AbortSignal limits: JournalPayloadLimits }): Promise<{ items: JournalReplacementItem[]; consumedBytes: number }> { const source = input.source @@ -206,6 +209,7 @@ async function decodeReplacementItems(input: { agent: input.agent, filePath: input.filePath, source: input.source, + signal: input.signal, maxSourceBytes: MAX_LEGACY_IMPORT_RECORD_BYTES }) : null diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts index e52b4cb07e9..071e24190a5 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-adopted-import.ts @@ -11,15 +11,36 @@ import { importLegacyTranscriptIntoJournal, prepareLegacyTranscriptImport } from '../agent-session-journal/journal-legacy-import' +import { throwIfSignalAborted } from '../../../shared/abort-signal-reason' +import type { StructuredAgentSessionAttachContext } from './structured-agent-session-attach-context' + +export async function prepareCreateTranscript( + context: StructuredAgentSessionAttachContext, + params: AgentSessionAttachParams +) { + const acquisition = context.runtimeState.acquireAborts.begin(params.envelope.sessionId) + try { + return await prepareAdoptedTranscript(params, acquisition.signal) + } finally { + acquisition.end() + } +} export async function prepareAdoptedTranscript( - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + signal?: AbortSignal ): Promise< | { ok: true; items: JournalReplacementItem[] | null } | { ok: false; refusal: AgentSessionWireRefusal } > { + if (!params.adopt) { + return { ok: true, items: null } + } try { - return { ok: true, items: await readAdoptedTranscript(params) } + throwIfSignalAborted(signal) + const items = await readAdoptedTranscript(params, signal) + throwIfSignalAborted(signal) + return { ok: true, items } } catch (error) { return { ok: false, @@ -36,7 +57,8 @@ export async function prepareAdoptedTranscript( // Validate before a new record can claim the provider conversation. async function readAdoptedTranscript( - params: AgentSessionAttachParams + params: AgentSessionAttachParams, + signal?: AbortSignal ): Promise { const adopt = params.adopt if (!adopt) { @@ -50,7 +72,7 @@ async function readAdoptedTranscript( const prepared = await prepareLegacyTranscriptImport({ agent: params.agent, sessionId: adopt.providerHandle.nativeId, - options: { filePath: adopt.transcriptPath, source: adopt.source } + options: { filePath: adopt.transcriptPath, source: adopt.source, signal } }) if (!prepared.ok) { throw new Error(prepared.error) diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts index b159505f97d..36407701b30 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-attach-flow.ts @@ -144,7 +144,7 @@ export async function performAttach( const lock = input.lock ?? heldStructuredAgentSessionAttachLock(input.acquireSignal) const preparedTranscript = store.getRecord(sessionId) ? { ok: true as const, items: null } - : await prepareAdoptedTranscript(params) + : await prepareAdoptedTranscript(params, lock.signal) if (!preparedTranscript.ok) { return preparedTranscript } diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts index 6958d2070f6..fe549a63443 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create-adoption.test.ts @@ -1,4 +1,4 @@ -import { writeFile, rm } from 'node:fs/promises' +import { writeFile, rm, mkdir, realpath, symlink, readFile } from 'node:fs/promises' import { join } from 'node:path' import { afterEach, beforeEach, expect, it, vi } from 'vitest' import { @@ -20,7 +20,26 @@ import { import type { AgentSessionAttachParams } from './structured-agent-session-attach' import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../shared/constants' import { openTestJournalHostDatabase } from '../agent-session-journal/journal-host-database-test-support' -import { codexProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { + claudeProviderHandle, + codexProviderHandle +} from '../../../shared/agent-session-provider-handle-encoding' +import { adoptedDirectoryProbes } from '../agent-session-journal/adopted-directory-probe' +import { writeOpenCodeSqliteDatabase } from '../../ai-vault/session-scanner-opencode-sqlite-fixture' +import { structuredAgentRuntimeRegistration } from '../../runtime/structured-agent-runtime-registrations' +import { structuredAgentsDeclaringNothing } from './structured-agent-session-adapter-router-test-support' +import { resolveAgentSessionLaunchDirectory } from '../../runtime/agent-session-launch-directory' +import { createClaudeStructuredLaunchResolver } from '../../runtime/structured-agent-launch-composition.test-support' + +vi.mock('../../ai-vault/session-scanner-opencode-sqlite-worker-spawn', async (original) => { + const query = await import('../transcript-opencode-sqlite-query') + return { + ...(await original()), + readOpenCodeTranscriptPageViaWorker: query.readOpenCodeTranscriptPage, + readOpenCodeTranscriptSignalViaWorker: async (args: { dbPath: string; sessionId: string }) => + query.readOpenCodeTranscriptSignal(args.dbPath, args.sessionId) + } +}) let rig: RestTestRig beforeEach(async () => { @@ -63,6 +82,207 @@ function anotherSession(params: AgentSessionAttachParams): AgentSessionAttachPar }) } +it.each([100, 1000])( + 'imports %s source rows into a linked workspace with one fresh probe and zero replay work', + async (size) => { + const physical = await realpath(rig.root) + const registered = join(rig.root, 'registered') + await symlink(physical, registered, process.platform === 'win32' ? 'junction' : 'dir') + const transcriptPath = join(rig.root, `${THREAD}.jsonl`) + // Captured CLI transcript metadata; relocate fixture identity/cwd into the isolated host. + const captured = ( + await readFile( + join(__dirname, '..', '__fixtures__', 'openclaude-0.31.0-local-command-rows.jsonl'), + 'utf8' + ) + ) + .split('\n') + .filter(Boolean) + .map((line) => JSON.parse(line)) + .find((row) => row.type === 'user' && !row.isMeta) + expect(captured.cwd).toBe('/tmp/project') + await writeFile( + transcriptPath, + `${Array.from({ length: size }, (_, index) => + JSON.stringify({ + ...captured, + uuid: `original-${index}`, + sessionId: THREAD, + cwd: physical, + message: { role: 'user', content: 'original physical-keyed context' } + }) + ).join('\n')}\n` + ) + const params = createTestParams(undefined, { + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: rig.root }, + adopt: { + providerHandle: claudeProviderHandle(THREAD, null), + transcriptPath, + source: { providerSessionId: THREAD, workspaceCwd: registered } + } + }) + const probe = vi.spyOn(adoptedDirectoryProbes, 'matches') + const publishTab = vi.fn(async () => { + expect(rig.store.getRecord(SESSION)?.providerHandleChain[0].handle.nativeId).toBe(THREAD) + }) + const result = await rig.host.create(CALLER, params, { publishTab }) + expect(result).toMatchObject({ + ok: true, + value: { + page: { + items: expect.arrayContaining([ + expect.objectContaining({ + body: expect.objectContaining({ + blocks: [{ type: 'text', text: 'original physical-keyed context' }] + }) + }) + ]) + } + } + }) + expect(publishTab).toHaveBeenCalledOnce() + expect(rig.store.getRecord(SESSION)?.accountHome).toEqual(params.accountHome) + expect(params.adopt?.source?.workspaceCwd).toBe(registered) + const record = rig.store.getRecord(SESSION)! + expect( + await resolveAgentSessionLaunchDirectory( + { store: rig.store, resolveWorkspacePath: async () => registered }, + record + ) + ).toBe(registered) + expect(record.providerHandleChain[0].handle.nativeId).toBe(THREAD) + const hasTranscript = vi.fn( + async ({ + providerSessionId, + claudeConfigDir + }: { + providerSessionId: string + claudeConfigDir: string + }) => + providerSessionId === THREAD && + claudeConfigDir === rig.root && + (await readFile(transcriptPath, 'utf8')).includes(THREAD) + ) + const launch = await createClaudeStructuredLaunchResolver({ + store: rig.store, + resolveWorkspacePath: async () => registered, + resolveCommand: () => process.execPath, + resolveInheritedEnv: async () => ({}), + resolveAuthPolicy: () => ({ account: 'system' }), + resolveLaunchArgs: () => [], + hasTranscript + })({ + identity: { + sessionId: SESSION, + workspaceId: record.location.workspaceId, + hostId: 'local', + agent: 'claude', + providerHandle: record.providerHandleChain[0].handle + } + }) + expect(launch).toMatchObject({ + cwd: registered, + providerSessionId: THREAD, + claudeConfigDir: rig.root, + options: { resume: THREAD } + }) + expect(hasTranscript).toHaveBeenCalledWith({ + providerSessionId: THREAD, + claudeConfigDir: rig.root + }) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + expect(probe).toHaveBeenCalledOnce() + await rig.host.flushAllStreamedEvents() + await rig.restart() + await rm(transcriptPath) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) + expect(probe).toHaveBeenCalledOnce() + } +) + +it('uses current selected ancestors, then refuses retargeted/foreign sources before ownership', async () => { + const physical = join(await realpath(rig.root), 'physical') + const moved = join(await realpath(rig.root), 'moved') + const registered = join(rig.root, 'registered') + await mkdir(physical) + await mkdir(moved) + await symlink(physical, registered, process.platform === 'win32' ? 'junction' : 'dir') + await symlink(moved, join(physical, 'pkg'), process.platform === 'win32' ? 'junction' : 'dir') + const sourceCwd = join(physical, 'pkg') + const workspaceCwd = join(registered, 'pkg') + expect(await realpath(workspaceCwd)).toBe(moved) + const transcriptPath = join(rig.root, `${THREAD}.jsonl`) + const save = async (sessionId: string, cwd: string) => + writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'user', + uuid: 'original', + sessionId, + cwd, + message: { role: 'user', content: 'ancestor context' } + })}\n` + ) + const params = createTestParams(undefined, { + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: rig.root }, + adopt: { + providerHandle: claudeProviderHandle(THREAD, null), + transcriptPath, + source: { providerSessionId: THREAD, workspaceCwd } + } + }) + const probe = vi.spyOn(adoptedDirectoryProbes, 'matches') + await save('wrong-provider', sourceCwd) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: false }) + expect(probe).not.toHaveBeenCalled() + await save(THREAD, join(rig.root, 'foreign')) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: false }) + expect(rig.store.listRecords()).toEqual([]) + await save(THREAD, sourceCwd) + await rm(registered) + await symlink(moved, registered, process.platform === 'win32' ? 'junction' : 'dir') + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: false }) + expect(rig.store.listRecords()).toEqual([]) + await rm(registered) + await symlink(physical, registered, process.platform === 'win32' ? 'junction' : 'dir') + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true }) + expect(probe).toHaveBeenLastCalledWith(workspaceCwd, sourceCwd, expect.any(AbortSignal)) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true, replayed: true }) +}) + +it.each(['opencode', 'opencode2'] as const)( + 'commits %s actual database history using the same linked-cwd admission', + async (agent) => { + const definition = structuredAgentRuntimeRegistration(agent)!.definition + await rig.dispose() + rig = await createRestTestRig({ agents: structuredAgentsDeclaringNothing([definition]) }) + const physical = await realpath(rig.root) + const registered = join(rig.root, 'registered') + await symlink(physical, registered, process.platform === 'win32' ? 'junction' : 'dir') + const transcriptPath = join(rig.root, 'opencode.db') + writeOpenCodeSqliteDatabase(transcriptPath, [ + { id: THREAD, directory: physical, turns: [{ role: 'user', parts: ['database context'] }] } + ]) + const params = createTestParams(undefined, { + agent, + provider: agent, + accountHome: { kind: 'opencode', locator: { kind: 'unmanaged' } }, + adopt: { + providerHandle: { agent, transport: definition.handleTransport, nativeId: THREAD }, + transcriptPath, + source: { providerSessionId: THREAD, workspaceCwd: registered } + } + }) + expect(await rig.host.create(CALLER, params)).toMatchObject({ ok: true }) + expect(rig.store.getRecord(SESSION)?.providerHandleChain[0].handle.nativeId).toBe(THREAD) + expect(rig.adapter.acquire).not.toHaveBeenCalled() + } +) + it('imports adoption at rest and resumes its original provider identity after its source disappears', async () => { const params = await adoptedParams() expect(await rig.host.create(CALLER, params)).toMatchObject({ diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts index 8bc7bd3d546..9af72af53bf 100644 --- a/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-create.ts @@ -15,7 +15,7 @@ import type { StructuredAgentSessionAttachContext } from './structured-agent-ses import { reserveRequestFor, type AgentSessionAttachParams } from './structured-agent-session-attach' import { openStructuredAgentSessionConversationJournal } from './structured-agent-session-conversation-open' import { - prepareAdoptedTranscript, + prepareCreateTranscript, importAdoptedTranscript } from './structured-agent-session-adopted-import' import { @@ -156,7 +156,7 @@ async function foundConversation( } const prepared = replayed ? { ok: true as const, items: null } - : await prepareAdoptedTranscript(params) + : await prepareCreateTranscript(context, params) if (!prepared.ok) { const { refusal } = prepared return { diff --git a/src/main/native-chat/agent-session-wire/structured-agent-session-directory-admission.test.ts b/src/main/native-chat/agent-session-wire/structured-agent-session-directory-admission.test.ts new file mode 100644 index 00000000000..e59649f5188 --- /dev/null +++ b/src/main/native-chat/agent-session-wire/structured-agent-session-directory-admission.test.ts @@ -0,0 +1,231 @@ +import { ChildProcess, type SpawnOptions } from 'node:child_process' +import type * as ChildProcessModule from 'node:child_process' +import { PassThrough } from 'node:stream' +import { writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import { createRestTestRig, type RestTestRig } from './structured-agent-session-rest-test-rig' +import { + createTestParams, + CREATE_TEST_CALLER as CALLER +} from './structured-agent-session-create-test-fixture' +import { + HOST_TEST_SESSION as SESSION, + HOST_TEST_THREAD as THREAD +} from './structured-agent-session-host-test-data' +import { claudeProviderHandle } from '../../../shared/agent-session-provider-handle-encoding' +import { computeAgentSessionPayloadFingerprint } from '../../../shared/agent-session-mutation-envelope' +import { attachFingerprintFields } from './structured-agent-session-attach' +import { StructuredAgentSessionTaskQueue } from './structured-agent-session-task-queue' +import { + adoptedDirectoryProbes, + ADOPTED_DIRECTORY_PROBE_SOURCE +} from '../agent-session-journal/adopted-directory-probe' + +const { spawn } = vi.hoisted(() => ({ spawn: vi.fn() })) +vi.mock('node:child_process', async (original) => ({ + ...(await original()), + spawn +})) + +class ProbeChild extends ChildProcess { + override pid = 1234 + override kill = vi.fn(() => true) + override stdin = new PassThrough() + override stdout = new PassThrough() + override stderr = new PassThrough() + reply(matched = true) { + this.stdout.write(JSON.stringify({ version: 1, matched })) + this.emit('close', 0, null) + } +} +let rig: RestTestRig +const children: ProbeChild[] = [] +beforeEach(async () => { + rig = await createRestTestRig({ idleSweep: { intervalMs: 60_000 } }) + spawn.mockImplementation((_program: string, _args: string[], _options: SpawnOptions) => { + const child = new ProbeChild() + children.push(child) + return child + }) +}) +afterEach(async () => { + for (const child of children.splice(0)) { + child.emit('close', null, 'SIGKILL') + } + vi.useRealTimers() + await rig.dispose() + vi.restoreAllMocks() + spawn.mockReset() + expect(adoptedDirectoryProbes.ownedCount).toBe(0) +}) +async function request() { + const transcriptPath = join(rig.root, `${THREAD}.jsonl`) + await writeFile( + transcriptPath, + `${JSON.stringify({ + type: 'user', + uuid: 'turn', + sessionId: THREAD, + cwd: '/physical/source', + message: { role: 'user', content: 'original context' } + })}\n` + ) + return createTestParams(undefined, { + agent: 'claude', + provider: 'claude', + accountHome: { variable: 'CLAUDE_CONFIG_DIR', path: rig.root }, + adopt: { + providerHandle: claudeProviderHandle(THREAD, null), + transcriptPath, + source: { providerSessionId: THREAD, workspaceCwd: '/selected/workspace' } + } + }) +} +async function dispatched() { + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + return children.at(-1)! +} +function uncommitted() { + expect(rig.store.listRecords()).toEqual([]) + expect(rig.store.listOperationRows()).toEqual([]) + expect(rig.host.collaboratorsForTests().sessions.size).toBe(0) +} + +it('settles a deadline once, retains an unexited child, ignores late proof and permits fresh success/replay', async () => { + const params = await request() + const publishTab = vi.fn(async () => {}) + const { runtimeState } = rig.host.collaboratorsForTests() + const tracked = vi.spyOn(StructuredAgentSessionTaskQueue.prototype, 'trackAttach') + const begin = vi.spyOn(runtimeState.acquireAborts, 'begin') + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + let settlements = 0 + const first = rig.host.create(CALLER, params, { publishTab }).then((value) => { + settlements++ + return value + }) + const child = await dispatched() + const tasks = tracked.mock.contexts[0] + if (!(tasks instanceof StructuredAgentSessionTaskQueue)) { + throw new Error('Create must register its actual attach task') + } + expect(tasks.hasPendingAttach(SESSION)).toBe(true) + const [program, args, options] = spawn.mock.calls[0] + expect(program).toBe(process.execPath) + expect(args).toEqual(['-e', ADOPTED_DIRECTORY_PROBE_SOURCE]) + expect(options).toMatchObject({ + cwd: dirname(process.execPath), + shell: false, + windowsHide: true, + env: { ELECTRON_RUN_AS_NODE: '1' } + }) + expect(options.env.NODE_OPTIONS).toBeUndefined() + expect(child.stdin.read()?.toString()).toBe( + JSON.stringify({ + version: 1, + workspaceCwd: '/selected/workspace', + sourceCwd: '/physical/source' + }) + ) + await vi.advanceTimersByTimeAsync(7_000) + expect(await first).toMatchObject({ + ok: false, + refusal: { createAttemptRefusedBeforeCommit: true } + }) + expect(settlements).toBe(1) + expect(tasks.hasPendingAttach(SESSION)).toBe(false) + runtimeState.acquireAborts.abort(SESSION, 'after timeout') + expect(begin.mock.results[0].value.signal.aborted).toBe(false) + expect(child.kill).toHaveBeenCalledWith('SIGKILL') + expect(adoptedDirectoryProbes.ownedCount).toBe(1) + uncommitted() + child.stdout.write(JSON.stringify({ version: 1, matched: true })) + await vi.advanceTimersByTimeAsync(0) + expect(adoptedDirectoryProbes.ownedCount).toBe(1) + expect(settlements).toBe(1) + expect(publishTab).not.toHaveBeenCalled() + expect(spawn).toHaveBeenCalledOnce() + uncommitted() + vi.useRealTimers() + const next = rig.host.create(CALLER, params, { publishTab }) + await vi.waitFor(() => expect(spawn).toHaveBeenCalledTimes(2)) + expect(adoptedDirectoryProbes.ownedCount).toBe(2) + children.at(-1)!.reply() + expect(await next).toMatchObject({ ok: true }) + expect(publishTab).toHaveBeenCalledOnce() + expect(adoptedDirectoryProbes.ownedCount).toBe(1) + child.emit('close', 0, null) + expect(adoptedDirectoryProbes.ownedCount).toBe(0) + await rig.host.flushAllStreamedEvents() + await rig.restart() + await expect( + rig.host.create(CALLER, { + ...params, + adopt: { providerHandle: claudeProviderHandle(THREAD, null) } + }) + ).resolves.toMatchObject({ ok: true, replayed: true }) + expect(spawn).toHaveBeenCalledTimes(2) +}) + +it.each(['create', 'attach'] as const)( + 'shutdown aborts preparation before draining %s; late close releases only process ownership', + async (method) => { + const params = await request() + if (method === 'attach') { + params.envelope.payloadFingerprint = computeAgentSessionPayloadFingerprint({ + method: 'agentSession.attach', + sessionId: SESSION, + fields: attachFingerprintFields(params) + }) + } + const pending = rig.host[method](CALLER, params) + const child = await dispatched() + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) + const aborts = rig.host.collaboratorsForTests().runtimeState.acquireAborts + const abortAll = vi.spyOn(aborts, 'abortAll') + const teardown = rig.host.flushAllStreamedEvents() + await vi.advanceTimersByTimeAsync(2_000) + await teardown + expect(abortAll).toHaveBeenCalledWith('closed by quit') + expect(await pending).toMatchObject({ ok: false }) + uncommitted() + expect(adoptedDirectoryProbes.ownedCount).toBe(1) + child.reply() + expect(adoptedDirectoryProbes.ownedCount).toBe(0) + expect(spawn).toHaveBeenCalledOnce() + } +) + +it.each(['no-match', 'read-error', 'spawn-error', 'invalid', 'truncated'] as const)( + 'refuses %s before commit and ends the acquisition handle', + async (outcome) => { + const params = await request() + const begin = vi.spyOn(rig.host.collaboratorsForTests().runtimeState.acquireAborts, 'begin') + if (outcome === 'spawn-error') { + spawn.mockImplementationOnce(() => { + throw new Error('spawn failed') + }) + } + const pending = rig.host.create(CALLER, params) + await vi.waitFor(() => expect(spawn).toHaveBeenCalled()) + if (outcome !== 'spawn-error') { + const child = children.at(-1)! + if (outcome === 'no-match') { + child.reply(false) + } else { + child.stdout.write(outcome === 'truncated' ? 'x'.repeat(4097) : 'invalid') + child.emit('close', outcome === 'read-error' ? 1 : 0, null) + } + } + expect(await pending).toMatchObject({ + ok: false, + refusal: { createAttemptRefusedBeforeCommit: true } + }) + const handle = begin.mock.results[0].value + // Ended handles no longer receive a later shutdown abort. + rig.host.collaboratorsForTests().runtimeState.acquireAborts.abortAll('after settlement') + expect(handle.signal.aborted).toBe(false) + uncommitted() + expect(adoptedDirectoryProbes.ownedCount).toBe(0) + } +) diff --git a/src/main/native-chat/structured-agent-session-source-admission.test.ts b/src/main/native-chat/structured-agent-session-source-admission.test.ts index 26321c143d5..0f041b57013 100644 --- a/src/main/native-chat/structured-agent-session-source-admission.test.ts +++ b/src/main/native-chat/structured-agent-session-source-admission.test.ts @@ -12,6 +12,7 @@ import { prepareLegacyTranscriptImport } from './agent-session-journal/journal-l import { prepareCodexAdoption } from './codex-adoption-preparation' import { codexAiVaultSessionResumePreparation } from '../codex/codex-ai-vault-session-resume' import * as codexHomes from '../codex/codex-home-paths' +import { adoptedDirectoryProbes } from './agent-session-journal/adopted-directory-probe' let root: string beforeEach(async () => { @@ -55,6 +56,58 @@ function params(agent: StructuredAgentId, transcriptPath: string): AgentSessionA } } +it('preserves exact/Codex and the complete Pi two-link and fallback acceptance without the optional probe', async () => { + const probe = vi + .spyOn(adoptedDirectoryProbes, 'matches') + .mockRejectedValue(new Error('probe unavailable')) + const transcriptPath = join(root, `${id}.jsonl`) + const save = (agent: 'claude' | 'pi', cwd: string, sessionId = id) => + file( + transcriptPath, + agent === 'pi' + ? [ + { type: 'session', id: sessionId, cwd }, + { type: 'message', id: 'turn', message: { role: 'user', content: 'hello' } } + ] + : [ + { + type: 'user', + sessionId, + cwd, + uuid: 'turn', + message: { role: 'user', content: 'hello' } + } + ] + ) + await save('claude', root) + expect(await prepareAdoptedTranscript(params('claude', transcriptPath))).toMatchObject({ + ok: true + }) + const left = join(root, 'left') + const right = join(root, 'right') + for (const link of [left, right]) { + await symlink(root, link, process.platform === 'win32' ? 'junction' : 'dir') + } + const pi = params('pi', transcriptPath) + pi.adopt!.source!.workspaceCwd = left + await save('pi', right) + expect(await prepareAdoptedTranscript(pi)).toMatchObject({ ok: true }) + // Both unavailable paths retain samePiDirectory's established normalize fallback. + pi.adopt!.source!.workspaceCwd = join(root, 'missing', 'pkg') + await save('pi', `${join(root, 'missing')}/./pkg`) + expect(await prepareAdoptedTranscript(pi)).toMatchObject({ ok: true }) + await save('pi', right, 'wrong-header') + expect(await prepareAdoptedTranscript(pi)).toMatchObject({ ok: false }) + await file(transcriptPath, [ + { type: 'session_meta', payload: { id } }, + { type: 'response_item', payload: { type: 'message', role: 'user', content: 'hello' } } + ]) + expect(await prepareAdoptedTranscript(params('codex', transcriptPath))).toMatchObject({ + ok: true + }) + expect(probe).not.toHaveBeenCalled() +}) + it('creates from the host hook path when Claude filename differs, then checks actual recorded cwd', async () => { const transcriptPath = await file(join(root, 'projects', 'folder', 'different-uuid.jsonl'), [ { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx index 691fc8dd78f..311af04748d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-panel-session-list-request.test.tsx @@ -1,8 +1,18 @@ // @vitest-environment happy-dom import { cleanup, render, screen, fireEvent } from '@testing-library/react' import { afterEach, beforeEach, expect, it, vi } from 'vitest' +import type { ComponentProps } from 'react' import { useAppStore } from '@/store' -import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { makeFolderWorkspace, makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' +import { + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { aiVaultSearchHitToSession } from './ai-vault-search-session' +import type { AiVaultSessionVirtualList } from './AiVaultSessionVirtualList' +import type { AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat' import { resolveAiVaultPanelSessionListRequest } from './ai-vault-panel-session-list-request' import { writeAiVaultViewOptions, @@ -16,6 +26,22 @@ type PanelRequest = { } const panelRequests = vi.hoisted((): PanelRequest[] => []) +const returned = vi.hoisted((): { sessions: AiVaultSession[] } => ({ sessions: [] })) +const panelRows = vi.hoisted( + (): { session: AiVaultSession; resume: AiVaultResumeInChatEligibility }[] => [] +) + +vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ + ...(await importOriginal>()), + readLocalRuntimeCapabilitiesOrUnknown: () => [ + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY + ] +})) +vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({ + ...(await importOriginal>()), + structuredAgentSessionLaunchFeasible: () => true +})) vi.mock('./ai-vault-session-refresh', () => ({ useAiVaultSessionRefresh: ( @@ -29,7 +55,7 @@ vi.mock('./ai-vault-session-refresh', () => ({ loading: false, refresh: vi.fn(), scanResult: null, - sessions: [], + sessions: returned.sessions, loadedSessionLimit: null } } @@ -43,12 +69,30 @@ vi.mock('./ai-vault-original-pane-actions', () => ({ vi.mock('./ai-vault-session-delete-action', () => ({ useAiVaultSessionDeleteAction: () => vi.fn() })) -vi.mock('./AiVaultSessionVirtualList', () => ({ AiVaultSessionVirtualList: () => null })) +vi.mock('./AiVaultSessionVirtualList', () => ({ + AiVaultSessionVirtualList: ({ + groups, + getSessionResumeInChat + }: Pick< + ComponentProps, + 'groups' | 'getSessionResumeInChat' + >) => { + panelRows.length = 0 + for (const group of groups) { + for (const session of group.sessions) { + panelRows.push({ session, resume: getSessionResumeInChat(session) }) + } + } + return null + } +})) const WORKTREE_ID = 'repo-1::/repo/wt' beforeEach(() => { panelRequests.length = 0 + returned.sessions = [] + panelRows.length = 0 useAppStore.setState(useAppStore.getInitialState(), true) useAppStore.setState({ activeRepoId: 'repo-1', @@ -85,3 +129,32 @@ it('builds the exact request the Session History panel sends for the active work fireEvent.click(screen.getByRole('radio', { name: /^Workspace$/ })) expect(panelRequests.at(-1)).toEqual(request) }) + +it.each([false, true])( + 'delivers alias rows to native resume in Workspace and All (bound: %s)', + async (bound) => { + const folderId = 'folder:example' + useAppStore.setState({ + activeWorktreeId: bound ? WORKTREE_ID : folderId, + folderWorkspaces: [makeFolderWorkspace({ id: 'example', folderPath: '/var/example' })] + }) + const session = { + ...aiVaultSearchHitToSession( + { ...searchHit(), agent: 'claude', cwd: '/private/var/example' }, + 'local' + ), + cwdAliases: ['/var/example'] + } + returned.sessions = [session] + const { default: AiVaultPanel } = await import('./AiVaultPanel') + render() + expect(panelRequests.at(-1)?.scopePaths).toContain('/var/example') + expect(panelRows).toEqual([{ session, resume: { available: true, workspaceId: folderId } }]) + expect(panelRows[0].session).toBe(session) + fireEvent.click(screen.getByRole('radio', { name: /^All$/ })) + expect(panelRequests.at(-1)?.scopePaths).toContain('/var/example') + expect(panelRows).toEqual([{ session, resume: { available: true, workspaceId: folderId } }]) + expect(panelRows[0].session.cwd).toBe('/private/var/example') + expect(panelRows[0].session.sessionId).toBe(session.sessionId) + } +) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts index 0f981da5b36..12418adecb7 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.test.ts @@ -1,5 +1,7 @@ -import { describe, expect, it } from 'vitest' +import { afterEach, describe, expect, it, vi } from 'vitest' import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' +import * as correspondence from '../../../../shared/ai-vault-session-cwd-aliases' +import { resolveAiVaultSessionResumeInChatEligibility } from './ai-vault-session-resume-in-chat' import { aiVaultSearchHitToSession, canResumeAiVaultSearchHit, @@ -7,7 +9,79 @@ import { withAiVaultHistoryPreviews } from './ai-vault-search-session' +afterEach(() => vi.restoreAllMocks()) + describe('withAiVaultHistoryPreviews', () => { + it('bounds identity indexing and correspondence work when history adds aliases at N and 10N', () => { + const copy = vi.spyOn(correspondence, 'withAiVaultSessionCwdAliases') + for (const size of [100, 1000]) { + let identityReads = 0 + const hits = Array.from({ length: size }, (_, index) => { + const row = aiVaultSearchHitToSession( + { ...searchHit(), sessionId: `session-${index}`, cwd: '/private/var/example' }, + 'local' + ) + return { + ...row, + get sessionId() { + identityReads += 1 + return row.sessionId + } + } + }) + const history = hits.map((hit) => ({ ...hit })) + expect(withAiVaultHistoryPreviews(hits, history).every((row) => !row.cwdAliases)).toBe(true) + const updated = history.map((row) => ({ + ...row, + cwdAliases: ['/var/example'], + get sessionId() { + identityReads += 1 + return row.sessionId + } + })) + identityReads = 0 + copy.mockClear() + const hydrated = withAiVaultHistoryPreviews(hits, updated) + expect(hydrated.every((row) => row.cwdAliases?.[0] === '/var/example')).toBe(true) + expect(copy).toHaveBeenCalledTimes(size) + expect(identityReads).toBeGreaterThanOrEqual(2 * size) + expect(identityReads).toBeLessThanOrEqual(4 * size) + } + }) + + it('restores resume correspondence when history arrives after a search hit, even with an existing preview', () => { + const hit = aiVaultSearchHitToSession( + { ...searchHit(), agent: 'claude', cwd: '/private/var/example' }, + 'local' + ) + const previewed = { + ...hit, + previewMessages: [{ role: 'user' as const, text: 'Keep this preview', timestamp: null }] + } + const history = { ...hit, cwdAliases: ['/var/example'] } + const resume = (session: typeof hit) => + resolveAiVaultSessionResumeInChatEligibility({ + session, + targetWorkspaceId: 'folder:example', + targetWorkspacePath: '/var/example', + structuredRouteAvailable: true + }) + expect(resume(withAiVaultHistoryPreviews([previewed], [])[0]).available).toBe(false) + const [hydrated] = withAiVaultHistoryPreviews([previewed], [history]) + expect(hydrated.previewMessages).toBe(previewed.previewMessages) + expect(resume(hydrated)).toEqual({ available: true, workspaceId: 'folder:example' }) + const redacted = { ...hit, filePath: '' } + expect(resume(withAiVaultHistoryPreviews([redacted], [history])[0]).available).toBe(true) + for (const rows of [ + [history, { ...history, id: 'duplicate' }], + [{ ...history, cwd: '/private/var/different' }], + [{ ...history, filePath: '/other-transcript' }], + [{ ...history, executionHostId: 'runtime:other' as const }] + ]) { + expect(withAiVaultHistoryPreviews([hit], rows)[0].cwdAliases).toBeUndefined() + } + }) + it("shows a paired server hit's conversation preview from its scanned history row", () => { // A relay hit arrives without its transcript path, so its row id differs from the scanned row's. const hitSession = aiVaultSearchHitToSession( diff --git a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts index 8a326994ce4..dbad71919f1 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-search-session.ts @@ -1,6 +1,7 @@ import type { AiVaultSearchHit } from '../../../../shared/ai-vault-search-types' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import type { ExecutionHostId } from '../../../../shared/execution-host' +import { withAiVaultSessionCwdAliases } from '../../../../shared/ai-vault-session-cwd-aliases' export function aiVaultSearchHitToSession( hit: AiVaultSearchHit, @@ -50,15 +51,19 @@ export function withAiVaultHistoryPreviews( } return hitSessions.map((hitSession) => { const row = historyByIdentity.get(previewIdentity(hitSession)) + const hydrated = + row && (!hitSession.filePath || hitSession.filePath === row.filePath) + ? withAiVaultSessionCwdAliases(hitSession, row) + : hitSession return row && hitSession.previewMessages.length === 0 ? { - ...hitSession, + ...hydrated, previewMessages: row.previewMessages, ...(row.previewMessagesTruncated ? { previewMessagesTruncated: true } : {}), ...(row.lastUserPrompt !== undefined ? { lastUserPrompt: row.lastUserPrompt } : {}), ...(row.firstUserPrompt !== undefined ? { firstUserPrompt: row.firstUserPrompt } : {}) } - : hitSession + : hydrated }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts index b978d9ad2b8..7e658cae06c 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-continuation.test.ts @@ -47,27 +47,38 @@ describe('AI Vault session continuation', () => { expect(canContinueAiVaultSessionInNewSession(session(), null)).toBe(false) }) - it('preserves the transcript, stopping point, and historical cwd', () => { - const request = prepareAiVaultSessionContinuation({ - session: session(), - targetWorktreeId: 'worktree-1', - targetWorkspacePath: '/Users/ada/Desktop/current-worktree' - }) - - expect(request).toMatchObject({ - worktreeId: 'worktree-1', - workspacePath: '/Users/ada/Desktop/current-worktree', - initialCwd: '/Users/ada/Desktop/Client App', - launchSource: 'sidebar', - source: { - sourceAgent: 'claude', - lastPrompt: 'Finish the editor refactor', - lastAssistantMessage: 'The component tests still need work.' + it.each([false, true])( + 'preserves the transcript, stopping point, and historical cwd (aliases: %s)', + (aliases) => { + const source = { + ...session(), + ...(aliases ? { cwdAliases: ['/Users/ada/Desktop/current-worktree'] } : {}) } - }) - expect(request.source.transcriptPath).toContain('session.jsonl') - expect(request.source.capturedText).toContain('assistant: The component tests still need work.') - }) + const request = prepareAiVaultSessionContinuation({ + session: source, + targetWorktreeId: 'worktree-1', + targetWorkspacePath: '/Users/ada/Desktop/current-worktree' + }) + + expect(request).toMatchObject({ + worktreeId: 'worktree-1', + workspacePath: '/Users/ada/Desktop/current-worktree', + initialCwd: '/Users/ada/Desktop/Client App', + launchSource: 'sidebar', + source: { + sourceAgent: 'claude', + sourceWorkingDirectory: source.cwd, + lastPrompt: 'Finish the editor refactor', + lastAssistantMessage: 'The component tests still need work.' + } + }) + expect(request.source.transcriptPath).toContain('session.jsonl') + expect(request.source.capturedText).toContain( + 'assistant: The component tests still need work.' + ) + expect(source.sessionId).toBe('claude-session-1') + } + ) it.each(['opencode', 'opencode2'] as const)( 'uses bounded %s vault previews for plain and synthetic database paths', diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts index 833c7f45477..8b1f1318bb8 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-projects.ts @@ -15,8 +15,8 @@ import type { Worktree } from '../../../../shared/worktree/types' import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' import { aiVaultSessionProjectCwdKey, - matchAiVaultProjectCandidates -} from './ai-vault-session-project-candidates' + matchAiVaultSessionCwdCandidates +} from '../../../../shared/ai-vault-session-cwd-candidates' import { folderGroupKey, folderLabel, @@ -264,7 +264,7 @@ function resolveSessionProject( return { kind: 'unknown', key: 'unknown', label: '' } } - const matches = matchAiVaultProjectCandidates(session, cwd, candidates, compareCandidates) + const matches = matchAiVaultSessionCwdCandidates(session, cwd, candidates, compareCandidates) const sessionHostId = normalizeExecutionHostId(session.executionHostId) const hostMatches = sessionHostId ? matches.filter((candidate) => candidate.hostKey === sessionHostId) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts index 0e084d8e890..c731e1fd8ba 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-launch.test.ts @@ -170,30 +170,41 @@ describe('resumeAiVaultSessionInNewChat', () => { } ) - it('reveals the workspace and opens chat before provider settlement', async () => { - let settle!: (value: StructuredAgentLaunchSettlement) => void - const settlement = new Promise((resolve) => { - settle = resolve - }) - mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { - args.beforeOpen?.('session-1') - return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement } - }) - - await resumeAiVaultSessionInNewChat(session, 'codex', 'worktree-1', 'resume-click') - - expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( - expect.objectContaining({ - plan: expect.objectContaining({ resumeFrom: { providerSessionId: 'provider-1' } }), - hooks: {} + it.each([false, true])( + 'reveals the workspace and opens chat before provider settlement (aliases: %s)', + async (aliases) => { + const source = { + ...session, + cwd: '/private/var/example', + ...(aliases ? { cwdAliases: ['/var/example'], sessionId: 'provider-1' } : {}) + } + let settle!: (value: StructuredAgentLaunchSettlement) => void + const settlement = new Promise((resolve) => { + settle = resolve }) - ) - expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('worktree-1', { - executionHostId: 'local' - }) - expect(mocks.toastError).not.toHaveBeenCalled() - settle({ kind: 'structured', sessionId: 'session-1' }) - }) + mocks.beginStructuredAgentSessionProvisionalLaunch.mockImplementation((args) => { + args.beforeOpen?.('session-1') + return { sessionId: 'session-1', tab: { id: 'agent-session:session-1' }, settlement } + }) + + await resumeAiVaultSessionInNewChat(source, 'codex', 'worktree-1', 'resume-click') + + expect(mocks.prepareAiVaultSessionForResume).toHaveBeenCalledWith(source) + expect(mocks.beginStructuredAgentSessionProvisionalLaunch).toHaveBeenCalledWith( + expect.objectContaining({ + plan: expect.objectContaining({ resumeFrom: { providerSessionId: 'provider-1' } }), + hooks: {} + }) + ) + expect(mocks.activateAndRevealWorkspace).toHaveBeenCalledWith('worktree-1', { + executionHostId: 'local' + }) + expect(mocks.toastError).not.toHaveBeenCalled() + expect(source.cwd).toBe('/private/var/example') + expect(source.sessionId).toBe(aliases ? 'provider-1' : session.sessionId) + settle({ kind: 'structured', sessionId: 'session-1' }) + } + ) // Where `opencode` is 1.x, only the OpenCode 2 chat can load a 2.x conversation. it.each([ diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts index 2807664c2b0..f800ab2219e 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.test.ts @@ -1,6 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { makeWorktree } from '../worktree-jump-palette-test-fixtures' const mocks = vi.hoisted(() => ({ owner: vi.fn<() => string | null>(() => 'local'), @@ -59,6 +63,41 @@ beforeEach(() => { ]) }) +it.each(['local', 'runtime:server-1'] as const)( + 'negotiates alias-only ordinary adoption with %s while preserving old routes', + (host) => { + mocks.owner.mockReturnValue(host) + const target = '/srv/orca' + const resolve = (agent: AiVaultSession['agent'], cwd: string) => + resolveAiVaultSessionResumeInChatForWorkspace({ + session: { ...codexSession, agent, executionHostId: host, cwd, cwdAliases: [target] }, + resumeState: { blocked: false, worktreeId: PAIRED_WORKSPACE, usesSessionWorktree: true }, + activeWorkspaceId: PAIRED_WORKSPACE, + targetState: { + folderWorkspaces: [], + projectGroups: [], + repos: [], + worktreesByRepo: { + 'repo-1': [makeWorktree(PAIRED_WORKSPACE, 'workspace', { path: target })] + } + }, + settings: { experimentalNativeChat: true } + }) + const old = [STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY] + const set = (capabilities: readonly string[]) => { + mocks.localCapabilities.mockReturnValue(capabilities) + mocks.state.runtimeStatusByEnvironmentId.set('server-1', { status: { capabilities } }) + } + set(old) + expect(resolve('claude', '/physical/orca')).toMatchObject({ available: false }) + expect(resolve('claude', target)).toMatchObject({ available: true }) + expect(resolve('pi', '/physical/orca')).toMatchObject({ available: true }) + expect(resolve('codex', '/elsewhere')).toMatchObject({ available: true }) + set([...old, STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY]) + expect(resolve('claude', '/physical/orca')).toMatchObject({ available: true }) + } +) + describe('resuming a conversation from history in a chat', () => { // A paired server cannot read a client-local transcript, matching terminal resume restrictions. it("is not offered in a paired server's workspace for a conversation recorded here", () => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts index 7a2f89e0418..8741bbdf86d 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat-workspace.ts @@ -11,10 +11,15 @@ import { adoptsCliConversations, CLI_CONVERSATION_ADOPTING_AGENTS } from '../../../../shared/structured-agent-cli-conversation-adoption' -import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { resolveAiVaultTargetWorkspacePath } from './ai-vault-session-launch-target' import { resolveAiVaultSessionResumeInChatEligibility, + aiVaultSessionResumeInChatWorkspaceMatters, + aiVaultSessionCwdMatchesWorkspace, type AiVaultResumeInChatEligibility } from './ai-vault-session-resume-in-chat' import { @@ -41,6 +46,11 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { const targetOwner = targetWorkspaceId ? resolveStructuredAgentSessionOwner(state, targetWorkspaceId) : null + const aliasDependent = + args.session.agent !== 'pi' && + aiVaultSessionResumeInChatWorkspaceMatters(args.session.agent) && + !aiVaultSessionCwdMatchesWorkspace(args.session.cwd, targetWorkspacePath) + const capabilities = targetOwner ? resolveHostCapabilities(state, targetOwner) : null return resolveAiVaultSessionResumeInChatEligibility({ session: args.session, targetWorkspaceId, @@ -60,9 +70,10 @@ export function resolveAiVaultSessionResumeInChatForWorkspace(args: { settings: args.settings }) ) && - resolveHostCapabilities(state, targetOwner)?.includes( - STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY - ) === true + (!aliasDependent || + capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY) === + true) && + capabilities?.includes(STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY) === true }) } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts index b42003cf31c..bb23d911d64 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'node:fs' import { describe, expect, it } from 'vitest' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { resolveAiVaultSessionResumeInChatEligibility } from './ai-vault-session-resume-in-chat' @@ -83,6 +84,58 @@ describe('resolveAiVaultSessionResumeInChatEligibility', () => { }) describe('workspace matching, which binds the agents that store conversations per folder', () => { + it('accepts host correspondence for cwd from recorded Claude output', () => { + const capture = readFileSync( + new URL( + '../../../../main/claude/__fixtures__/claude-adapter-capture-early-steer.jsonl', + import.meta.url + ), + 'utf8' + ) + const cwds = capture + .trim() + .split('\n') + .flatMap((line) => { + const event: unknown = JSON.parse(line) + if (typeof event !== 'object' || !event || !('frame' in event)) { + return [] + } + const frame = event.frame + return typeof frame === 'object' && frame && 'cwd' in frame && typeof frame.cwd === 'string' + ? [frame.cwd] + : [] + }) + expect(cwds).toEqual(['/tmp/scrubbed-cwd']) + const cwd = cwds[0] + expect( + eligibility({ + session: session({ cwd, cwdAliases: [`/private${cwd}`] }), + targetWorkspacePath: `/private${cwd}` + }).available + ).toBe(true) + }) + + it.each(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])( + 'offers a %s row when its host proves the registered spelling of cwd', + (agent) => { + const row = { + ...session({ agent, cwd: '/private/var/example' }), + cwdAliases: ['/var/example'] + } + expect(eligibility({ session: row, targetWorkspacePath: '/var/example' })).toMatchObject({ + available: true + }) + expect(eligibility({ session: row, targetWorkspacePath: '/var' })).toEqual({ + available: false, + reason: 'workspace' + }) + expect(eligibility({ session: row, structuredRouteAvailable: false })).toEqual({ + available: false, + reason: 'workspace' + }) + } + ) + it.each(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])( 'refuses a %s row whose conversation was recorded in another workspace', (agent) => { diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts index c64c6926f03..59c34b843ae 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-resume-in-chat.ts @@ -38,21 +38,28 @@ export function aiVaultSessionResumeInChatWorkspaceMatters( /** Compare with shared runtime rules; POSIX case folding would conflate distinct directories. */ export function aiVaultSessionCwdMatchesWorkspace( cwd: string | null | undefined, - workspacePath: string | null | undefined + workspacePath: string | null | undefined, + cwdAliases: readonly string[] = [] ): boolean { if (!cwd || !workspacePath) { return false } - return ( - normalizeRuntimePathForComparison(cwd.trim()) === - normalizeRuntimePathForComparison(workspacePath.trim()) + const workspace = normalizeRuntimePathForComparison(workspacePath.trim()) + return [cwd, ...cwdAliases].some( + (path) => normalizeRuntimePathForComparison(path.trim()) === workspace ) } export function resolveAiVaultSessionResumeInChatEligibility(args: { session: Pick< AiVaultSession, - 'agent' | 'cwd' | 'filePath' | 'executionHostId' | 'messageCount' | 'previewMessages' + | 'agent' + | 'cwd' + | 'cwdAliases' + | 'filePath' + | 'executionHostId' + | 'messageCount' + | 'previewMessages' > & { structuredSession?: AiVaultSession['structuredSession'] } targetWorkspaceId: string | null targetWorkspacePath: string | null @@ -83,7 +90,7 @@ export function resolveAiVaultSessionResumeInChatEligibility(args: { } if ( aiVaultSessionResumeInChatWorkspaceMatters(session.agent) && - !aiVaultSessionCwdMatchesWorkspace(session.cwd, args.targetWorkspacePath) + !aiVaultSessionCwdMatchesWorkspace(session.cwd, args.targetWorkspacePath, session.cwdAliases) ) { return { available: false, reason: 'workspace' } } diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx index 72120cb84d8..55df9f88d38 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree-map.test.tsx @@ -313,11 +313,17 @@ describe('folder workspace resume targets', () => { it.each([folderKey, worktreeA.id, null])( 'resumes a folder conversation and its subagent in the folder with active %s', (activeWorktreeId) => { - const parent = makeSession({ agent: 'claude', id: 'claude:parent', cwd: '/work/notes' }) + const parent = makeSession({ + agent: 'claude', + id: 'claude:parent', + cwd: '/physical/notes', + cwdAliases: ['/work/notes'] + }) const child = makeSession({ agent: 'claude', id: 'claude:child', - cwd: '/work/notes', + cwd: '/physical/notes', + cwdAliases: ['/work/notes'], subagent: { parentSessionId: parent.sessionId, agentType: null, status: null } }) const { result } = renderHook(() => diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts index 7357631819b..4b324ee836f 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts +++ b/src/renderer/src/components/right-sidebar/ai-vault-session-worktree.test.ts @@ -80,6 +80,40 @@ function makeRepo(overrides: Partial = {}): Repo { } describe('resolveAiVaultSessionWorktreeInfo', () => { + it('ranks each same-host candidate by its closest cwd spelling, independent of link length or alias order', () => { + const parent = makeWorktree({ id: 'parent', path: '/a-very-long-parent-link' }) + const child = makeWorktree({ id: 'child', path: '/short', priorWorktreeIds: ['repo-1::/old'] }) + const foreign = makeWorktree({ id: 'foreign', path: '/other-host', hostId: 'runtime:other' }) + for (const cwdAliases of [ + ['/a-very-long-parent-link/child', '/short/nested', '/short', '/other-host'], + ['/other-host', '/short', '/short/nested', '/a-very-long-parent-link/child'] + ]) { + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/physical/child', cwdAliases }, + worktrees: [parent, foreign, child], + activeWorktreeId: 'parent' + }) + ).toMatchObject({ worktreeId: 'child', path: '/short', status: 'active' }) + } + }) + + it('keeps current-path ownership ahead of a prior-path alias tie', () => { + const prior = makeWorktree({ + id: 'renamed', + path: '/renamed', + priorWorktreeIds: ['repo-1::/link'] + }) + const current = makeWorktree({ id: 'current', path: '/link' }) + expect( + resolveAiVaultSessionWorktreeInfo({ + session: { ...baseSession, cwd: '/physical', cwdAliases: ['/link'] }, + worktrees: [prior, current], + activeWorktreeId: null + })?.worktreeId + ).toBe('current') + }) + it('marks the selected owning worktree as current', () => { const worktree = makeWorktree() diff --git a/src/renderer/src/components/right-sidebar/use-subagent-sessions.test.tsx b/src/renderer/src/components/right-sidebar/use-subagent-sessions.test.tsx new file mode 100644 index 00000000000..f984c6e7a24 --- /dev/null +++ b/src/renderer/src/components/right-sidebar/use-subagent-sessions.test.tsx @@ -0,0 +1,193 @@ +// @vitest-environment happy-dom +import { act, renderHook } from '@testing-library/react' +import { afterEach, expect, it, vi } from 'vitest' +import { useAppStore } from '@/store' +import { aiVaultSearchHitToSession } from './ai-vault-search-session' +import { searchHit } from '../../../../shared/ai-vault-search-test-fixture' +import * as correspondence from '../../../../shared/ai-vault-session-cwd-aliases' +import { + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' +import { makeFolderWorkspace, makeWorktree } from '../worktree-jump-palette-test-fixtures' +import { aiVaultSessionOwnerWorkspaces } from './ai-vault-session-worktree' +import { resolveAiVaultHistoryRowResume } from './ai-vault-session-resume-in-chat-workspace' +import { useSubagentSessions } from './use-subagent-sessions' +import type { AiVaultListResult } from '../../../../shared/ai-vault-types' +import { reuseAiVaultListResult } from './ai-vault-session-identity' + +vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ + ...(await importOriginal>()), + readLocalRuntimeCapabilitiesOrUnknown: () => [ + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY + ] +})) +vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({ + ...(await importOriginal>()), + structuredAgentSessionLaunchFeasible: () => true +})) + +afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() +}) + +function parentSession() { + return aiVaultSearchHitToSession( + { ...searchHit(), agent: 'omp', cwd: '/private/var/example' }, + 'local' + ) +} + +it('bounds loaded-child correspondence work at N and 10N without alias-only refetches', async () => { + const copy = vi.spyOn(correspondence, 'withAiVaultSessionCwdAliases') + for (const size of [100, 1000]) { + copy.mockClear() + const parent = parentSession() + const children = Array.from({ length: size }, (_, index) => ({ + ...parent, + sessionId: `child-${index}`, + filePath: `/child-${index}.jsonl`, + subagent: { parentSessionId: parent.sessionId, agentType: null, status: null } + })) + const list = vi.fn().mockResolvedValue({ sessions: children, issues: [] }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { listSubagentSessions: list } } + }) + const { result, rerender, unmount } = renderHook(useSubagentSessions, { initialProps: parent }) + await act(async () => {}) + expect(result.current.status).toBe('loaded') + expect(result.current.sessions).toBe(children) + expect(copy).not.toHaveBeenCalled() + rerender({ ...parent, title: 'No aliases yet' }) + expect(result.current.sessions).toBe(children) + expect(copy).not.toHaveBeenCalled() + copy.mockClear() + const updated = { ...parent, cwdAliases: ['/var/example'] } + rerender(updated) + expect(result.current.sessions.every((row) => row.cwdAliases?.[0] === '/var/example')).toBe( + true + ) + expect(copy).toHaveBeenCalledTimes(size) + const projected = result.current.sessions + copy.mockClear() + rerender({ ...updated, title: 'Renamed parent' }) + expect(copy).not.toHaveBeenCalled() + expect(result.current.sessions).toBe(projected) + let published: AiVaultListResult = { sessions: [updated], issues: [], scannedAt: '' } + for (const title of ['Transport rename', 'Another metadata update']) { + const incoming = structuredClone(published) + incoming.sessions[0].title = title + incoming.sessions[0].totalTokens += 1 + published = reuseAiVaultListResult(published, incoming) + const delivered = published.sessions[0] + expect(delivered).toBe(incoming.sessions[0]) + expect(delivered.cwdAliases).not.toBe(updated.cwdAliases) + expect(delivered.cwdAliases).toEqual(updated.cwdAliases) + rerender(delivered) + expect(copy).not.toHaveBeenCalled() + expect(result.current.sessions).toBe(projected) + } + rerender({ ...updated, cwdAliases: ['/var/relinked'] }) + expect(copy).toHaveBeenCalledTimes(size) + expect(result.current.sessions.every((row) => row.cwdAliases?.[0] === '/var/relinked')).toBe( + true + ) + copy.mockClear() + rerender(parent) + expect(copy).not.toHaveBeenCalled() + expect(result.current.sessions).toBe(children) + expect(list).toHaveBeenCalledTimes(1) + for (const changed of [ + { cwd: '/private/var/other' }, + { executionHostId: 'runtime:other' as const }, + { filePath: '//wsl.localhost/Ubuntu/parent.jsonl' }, + { agent: 'claude' as const } + ]) { + rerender(updated) + await act(async () => {}) + expect(result.current.sessions.every((row) => row.cwdAliases?.[0] === '/var/example')).toBe( + true + ) + rerender({ ...updated, ...changed }) + await act(async () => {}) + expect(result.current.sessions.every((row, index) => row === children[index])).toBe(true) + } + unmount() + } +}) + +it('derives loaded child and grandchild correspondence from current same-cwd evidence without refetching', async () => { + const parent = parentSession() + const child = { + ...parent, + sessionId: 'child', + filePath: '/child.jsonl', + subagent: { parentSessionId: parent.sessionId, agentType: null, status: null } + } + const list = vi.fn().mockResolvedValue({ sessions: [child], issues: [] }) + Object.defineProperty(window, 'api', { + configurable: true, + value: { aiVault: { listSubagentSessions: list } } + }) + useAppStore.setState(useAppStore.getInitialState(), true) + useAppStore.setState({ + folderWorkspaces: [makeFolderWorkspace({ id: 'example', folderPath: '/var/example' })], + worktreesByRepo: { 'repo-1': [makeWorktree('elsewhere', 'elsewhere', { path: '/elsewhere' })] } + }) + const state = useAppStore.getState() + const resume = (session: typeof parent) => + resolveAiVaultHistoryRowResume({ + session, + worktreeInfo: null, + worktrees: aiVaultSessionOwnerWorkspaces( + state.worktreesByRepo['repo-1'], + state.folderWorkspaces + ), + repos: state.repos, + activeWorktreeId: 'elsewhere', + targetState: state, + settings: state.settings + }) + const { result, rerender, unmount } = renderHook(useSubagentSessions, { initialProps: parent }) + await act(async () => {}) + expect(resume(result.current.sessions[0]).resumeInChat.available).toBe(false) + rerender({ ...parent, cwdAliases: ['/var/example'] }) + const inherited = result.current.sessions[0] + expect(resume(inherited)).toEqual({ + resumeState: { blocked: false, worktreeId: 'folder:example', usesSessionWorktree: true }, + resumeInChat: { available: true, workspaceId: 'folder:example' } + }) + expect(inherited.cwd).toBe(parent.cwd) + expect(inherited.sessionId).toBe(child.sessionId) + expect(list).toHaveBeenCalledTimes(1) + unmount() + + const grandchild = { + ...child, + sessionId: 'grandchild', + subagent: { ...child.subagent, parentSessionId: child.sessionId } + } + const different = { ...grandchild, sessionId: 'different', cwd: '/private/var/other' } + const foreign = { ...grandchild, sessionId: 'foreign', executionHostId: 'runtime:other' as const } + const wsl = { + ...grandchild, + sessionId: 'wsl', + filePath: '//wsl.localhost/Ubuntu/grandchild.jsonl' + } + list.mockResolvedValue({ sessions: [grandchild, different, foreign, wsl], issues: [] }) + const nested = renderHook(useSubagentSessions, { initialProps: inherited }) + await act(async () => {}) + expect(nested.result.current.sessions.map((session) => session.cwdAliases)).toEqual([ + ['/var/example'], + undefined, + undefined, + undefined + ]) + expect( + nested.result.current.sessions.map((session) => resume(session).resumeInChat.available) + ).toEqual([true, false, false, false]) + nested.unmount() +}) diff --git a/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts b/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts index b86df136709..d8021e9811e 100644 --- a/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts +++ b/src/renderer/src/components/right-sidebar/use-subagent-sessions.ts @@ -1,5 +1,7 @@ -import { useEffect, useState } from 'react' +import { useEffect, useMemo, useState } from 'react' +import { useShallow } from 'zustand/react/shallow' import type { AiVaultSession } from '../../../../shared/ai-vault-types' +import { withAiVaultSessionCwdAliases } from '../../../../shared/ai-vault-session-cwd-aliases' type SubagentListState = { sessions: AiVaultSession[] @@ -51,8 +53,20 @@ export function useSubagentSessions( session.modifiedAt, attempt ]) + const { agent, cwd, executionHostId, filePath } = session + // IPC clones equal alias arrays when only parent metadata changes. + const retainCwdAliases = useShallow((aliases: AiVaultSession['cwdAliases']) => aliases) + const cwdAliases = retainCwdAliases(session.cwdAliases) + const sessions = useMemo(() => { + if (!cwdAliases?.length) { + return state.sessions + } + const source = { agent, cwd, cwdAliases, executionHostId, filePath } + return state.sessions.map((child) => withAiVaultSessionCwdAliases(child, source)) + }, [state.sessions, agent, cwd, cwdAliases, executionHostId, filePath]) return { ...state, + sessions, showLoading: showLoading && state.status === 'loading', retry: () => setAttempt((value) => value + 1) } diff --git a/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts b/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts index a5f0c35f00c..e21f4129094 100644 --- a/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts +++ b/src/renderer/src/components/tab-bar/tab-session-history-switch.parity.test.ts @@ -8,20 +8,25 @@ import { makeRepo, makeWorktree } from '../worktree-jump-palette-test-fixtures' import type { AiVaultSession } from '../../../../shared/ai-vault-types' import { getDefaultSettings } from '../../../../shared/constants' import { folderWorkspaceKey } from '../../../../shared/workspace-scope' -import { STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY } from '../../../../shared/protocol-version' +import { + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY +} from '../../../../shared/protocol-version' import { resolveAiVaultSessionRowChatActions } from '../right-sidebar/ai-vault-session-chat-action' import { useAiVaultPanelSessionResume } from '../right-sidebar/use-ai-vault-panel-session-resume' +import { resolveAiVaultPanelSessionListRequest } from '../right-sidebar/ai-vault-panel-session-list-request' import { resolveTabSessionSwitch, + lookupTabSessionHistoryRow, type TabSessionHistorySubject } from './tab-session-history-switch' +const { readLocalCapabilities } = vi.hoisted(() => ({ readLocalCapabilities: vi.fn() })) + // Stage host IPC capabilities only; run the shared panel/tab composition for real. vi.mock('@/runtime/local-runtime-capabilities', async (importOriginal) => ({ ...(await importOriginal>()), - readLocalRuntimeCapabilitiesOrUnknown: () => [ - STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY - ] + readLocalRuntimeCapabilitiesOrUnknown: readLocalCapabilities })) vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({ ...(await importOriginal>()), @@ -86,19 +91,25 @@ function panelTargets(state: AppState, session: AiVaultSession) { settings: state.settings }) ) - return resolveAiVaultSessionRowChatActions({ - session, - originalPaneTarget: null, - structuredSessionOpen: false, - resumeState: result.current.getSessionResumeState(session), - resumeInChat: result.current.getSessionResumeInChat(session), - searchHit: undefined - }) + return { + worktreeInfo: result.current.getSessionWorktreeInfo(session), + ...resolveAiVaultSessionRowChatActions({ + session, + originalPaneTarget: null, + structuredSessionOpen: false, + resumeState: result.current.getSessionResumeState(session), + resumeInChat: result.current.getSessionResumeInChat(session), + searchHit: undefined + }) + } } const request = { scopePaths: [], executionHostScope: 'local', sessionLimit: 250 } as const beforeEach(() => { + readLocalCapabilities.mockReturnValue([ + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY + ]) makeState() }) @@ -180,40 +191,61 @@ describe('the tab menu offers exactly what the Session History row offers', () = expect(resolveTabSessionSwitch(state, emptyChat, subject)).toBeNull() }) - it("Switch to Chat in the conversation's own folder workspace", () => { - useAppStore.setState({ - folderWorkspaces: [ - { - id: 'folder-1', - projectGroupId: 'group-1', - name: 'Notes', - folderPath: '/work/notes', - executionHostId: 'local', - linkedTask: null, - comment: '', - isArchived: false, - isUnread: false, - isPinned: false, - sortOrder: 0, - lastActivityAt: 1, - createdAt: 1, - updatedAt: 1 - } - ] - }) - const state = useAppStore.getState() - const session = row({ cwd: '/work/notes' }) - const panel = panelTargets(state, session) - - expect(panel.resumeInNewChatWorkspaceId).toBe(folderWorkspaceKey('folder-1')) - expect( - resolveTabSessionSwitch(state, session, { + it.each([true, false])( + 'symlinked folder panel/tab parity with linked-cwd support=%s and another workspace active', + async (linkedCwd) => { + readLocalCapabilities.mockReturnValue([ + STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + ...(linkedCwd ? [STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY] : []) + ]) + useAppStore.setState({ + folderWorkspaces: [ + { + id: 'folder-1', + projectGroupId: 'group-1', + name: 'Notes', + folderPath: '/var/notes', + executionHostId: 'local', + linkedTask: null, + comment: '', + isArchived: false, + isUnread: false, + isPinned: false, + sortOrder: 0, + lastActivityAt: 1, + createdAt: 1, + updatedAt: 1 + } + ] + }) + const state = useAppStore.getState() + const session = row({ cwd: '/private/var/notes', cwdAliases: ['/var/notes'] }) + const panel = panelTargets(state, session) + const subject: TabSessionHistorySubject = { kind: 'cli', agent: 'claude', providerSessionId: session.sessionId, - workspaceId: WORKTREE_ID, - request + workspaceId: folderWorkspaceKey('folder-1'), + request: resolveAiVaultPanelSessionListRequest(state, folderWorkspaceKey('folder-1')) + } + const list = vi.fn(async () => ({ sessions: [session], issues: [], scannedAt: '' })) + const found = await lookupTabSessionHistoryRow(subject, list, { + requestToken: 'symlink', + isCancelled: () => false }) - ).toEqual({ action: 'resume-in-new-chat', worktreeId: folderWorkspaceKey('folder-1') }) - }) + expect(list).toHaveBeenCalledWith(expect.objectContaining({ scopePaths: ['/var/notes'] })) + expect(found).toBe(session) + + expect(list).toHaveBeenCalledTimes(1) + expect(panel.worktreeInfo?.worktreeId).toBe(folderWorkspaceKey('folder-1')) + expect(panel.resumeInNewChatWorkspaceId).toBe( + linkedCwd ? folderWorkspaceKey('folder-1') : null + ) + expect(found && resolveTabSessionSwitch(state, found, subject)).toEqual( + linkedCwd + ? { action: 'resume-in-new-chat', worktreeId: folderWorkspaceKey('folder-1') } + : null + ) + } + ) }) diff --git a/src/shared/ai-vault-session-cwd-aliases.ts b/src/shared/ai-vault-session-cwd-aliases.ts new file mode 100644 index 00000000000..ad174487405 --- /dev/null +++ b/src/shared/ai-vault-session-cwd-aliases.ts @@ -0,0 +1,28 @@ +import type { AiVaultSession } from './ai-vault-types' +import { sessionFileExecutionNamespace } from './ai-vault-scope-cwd-match' +import { normalizeRuntimePathForComparison } from './cross-platform-path' +import { normalizeExecutionHostId } from './execution-host' + +/** A relationship or provider id alone does not prove that two rows recorded the same directory. */ +export function withAiVaultSessionCwdAliases( + session: AiVaultSession, + source: Pick +): AiVaultSession { + if ( + !source.cwdAliases?.length || + !session.cwd || + !source.cwd || + session.agent !== source.agent || + normalizeExecutionHostId(session.executionHostId) !== + normalizeExecutionHostId(source.executionHostId) || + sessionFileExecutionNamespace(session.filePath) !== + sessionFileExecutionNamespace(source.filePath) || + normalizeRuntimePathForComparison(session.cwd) !== normalizeRuntimePathForComparison(source.cwd) + ) { + return session + } + return { + ...session, + cwdAliases: [...new Set([...(session.cwdAliases ?? []), ...source.cwdAliases])] + } +} diff --git a/src/shared/ai-vault-session-cwd-candidates.test.ts b/src/shared/ai-vault-session-cwd-candidates.test.ts new file mode 100644 index 00000000000..152cf49de9f --- /dev/null +++ b/src/shared/ai-vault-session-cwd-candidates.test.ts @@ -0,0 +1,21 @@ +import { expect, it, vi } from 'vitest' +import { matchAiVaultSessionCwdCandidates } from './ai-vault-session-cwd-candidates' +import { createNormalizedPathInsideOrEqualMatcher } from './cross-platform-path' + +it('keeps alias matching linear in candidate roots at the maximum host alias count', () => { + const session = { + filePath: '/session.jsonl', + cwdAliases: Array.from({ length: 64 }, (_, i) => `/alias-${i}/child`) + } + for (const size of [100, 1000]) { + const owns = vi.fn(createNormalizedPathInsideOrEqualMatcher('/alias-63')) + const candidates = Array.from({ length: size }, () => ({ + normalizedPath: '/alias-63', + ownsNormalizedCwd: owns + })) + expect( + matchAiVaultSessionCwdCandidates(session, '/physical/child', candidates, () => 0) + ).toHaveLength(size) + expect(owns).toHaveBeenCalledTimes(size * 65) + } +}) diff --git a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts b/src/shared/ai-vault-session-cwd-candidates.ts similarity index 82% rename from src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts rename to src/shared/ai-vault-session-cwd-candidates.ts index 0b2e545b431..5ec784980bf 100644 --- a/src/renderer/src/components/right-sidebar/ai-vault-session-project-candidates.ts +++ b/src/shared/ai-vault-session-cwd-candidates.ts @@ -1,8 +1,8 @@ -import { sessionFileExecutionNamespace } from '../../../../shared/ai-vault-scope-cwd-match' -import type { AiVaultSession } from '../../../../shared/ai-vault-types' -import { normalizeRuntimePathForComparison } from '../../../../shared/cross-platform-path' +import { sessionFileExecutionNamespace } from './ai-vault-scope-cwd-match' +import type { AiVaultSession } from './ai-vault-types' +import { normalizeRuntimePathForComparison } from './cross-platform-path' -type ProjectCandidate = { +type CwdCandidate = { normalizedPath: string ownsNormalizedCwd: (normalizedCwd: string, filePath?: string) => boolean } @@ -19,7 +19,7 @@ export function aiVaultSessionProjectCwdKey( } /** Try cwd and host-reported symlink aliases, best match first. */ -export function matchAiVaultProjectCandidates( +export function matchAiVaultSessionCwdCandidates( session: Pick, cwd: string, candidates: readonly T[], diff --git a/src/shared/ai-vault-session-worktree.ts b/src/shared/ai-vault-session-worktree.ts index d400ac2a9bd..17f3824889a 100644 --- a/src/shared/ai-vault-session-worktree.ts +++ b/src/shared/ai-vault-session-worktree.ts @@ -16,6 +16,7 @@ import { } from './cross-platform-path' import type { AiVaultSession } from './ai-vault-types' import type { Worktree } from './worktree/types' +import { matchAiVaultSessionCwdCandidates } from './ai-vault-session-cwd-candidates' /** The workspace fields that decide ownership; folder workspaces pass their folder row. */ export type AiVaultSessionOwnerWorkspace = Pick< @@ -48,6 +49,7 @@ type WorktreeCandidate = { // Precomputed so a 500-session scan doesn't re-normalize ~500 roots per session. ownsNormalizedCwd: (normalizedCwd: string) => boolean normalizedPathLength: number + normalizedPath: string } export function resolveAiVaultSessionWorktreeInfo({ @@ -87,11 +89,12 @@ function resolveWorktreeInfoFromCandidates( } const sessionHostId = normalizeExecutionHostId(session.executionHostId) - const normalizedCwd = normalizeRuntimePathForComparison(session.cwd) - const matched = candidates - .filter((candidate) => candidate.ownsNormalizedCwd(normalizedCwd)) - .filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId) - .sort(compareWorktreeCandidates) + const matched = matchAiVaultSessionCwdCandidates( + session, + session.cwd, + candidates.filter((candidate) => !sessionHostId || candidate.hostId === sessionHostId), + compareWorktreeCandidates + ) const best = matched[0] if (!best) { @@ -217,41 +220,40 @@ function buildWorktreeCandidates( normalizeExecutionHostId(worktree.hostId) ?? (repo ? getRepoExecutionHostId(repo) : LOCAL_EXECUTION_HOST_ID) if (hasUsablePath(worktree.path)) { - candidates.push(makeWorktreeCandidate(worktree, worktree.path, hostId, 'current-path')) + candidates.push(...makeWorktreeCandidates(worktree, worktree.path, hostId, 'current-path')) } for (const priorWorktreeId of worktree.priorWorktreeIds ?? []) { const parsed = splitWorktreeIdForFilesystem(priorWorktreeId) if (!parsed || parsed.repoId !== worktree.repoId || !hasUsablePath(parsed.worktreePath)) { continue } - candidates.push(makeWorktreeCandidate(worktree, parsed.worktreePath, hostId, 'prior-path')) + candidates.push( + ...makeWorktreeCandidates(worktree, parsed.worktreePath, hostId, 'prior-path') + ) } } return candidates } -function makeWorktreeCandidate( +function makeWorktreeCandidates( worktree: AiVaultSessionOwnerWorkspace, path: string, hostId: ExecutionHostId, source: WorktreeCandidate['source'] -): WorktreeCandidate { - const ownsCwd = createNormalizedPathInsideOrEqualMatcher(path) +): WorktreeCandidate[] { // A WSL UNC root also owns sessions recorded under its Linux-native cwd. const wslPath = parseWslUncPath(path) - const ownsCwdViaWslAlias = wslPath - ? createNormalizedPathInsideOrEqualMatcher(wslPath.linuxPath) - : null - return { + const cwdPaths = wslPath ? [path, wslPath.linuxPath] : [path] + return cwdPaths.map((cwdPath): WorktreeCandidate => ({ worktree, path, hostId, status: worktree.isArchived ? 'archived' : 'active', source, - ownsNormalizedCwd: (normalizedCwd) => - ownsCwd(normalizedCwd) || (ownsCwdViaWslAlias?.(normalizedCwd) ?? false), + ownsNormalizedCwd: createNormalizedPathInsideOrEqualMatcher(cwdPath), + normalizedPath: normalizeRuntimePathForComparison(cwdPath), normalizedPathLength: normalizeRuntimePathForComparison(path).length - } + })) } function hasUsablePath(pathValue: string): boolean { diff --git a/src/shared/ai-vault-types.ts b/src/shared/ai-vault-types.ts index 1d585003d00..746042b17b8 100644 --- a/src/shared/ai-vault-types.ts +++ b/src/shared/ai-vault-types.ts @@ -114,7 +114,7 @@ export type AiVaultSession = { sessionId: string title: string cwd: string | null - /** The host's spellings of `cwd` under a symlinked scope path. Scope matching only; resume reads `cwd`. */ + /** Host-proven spellings of `cwd` for matching scopes and resume targets; launch paths remain separate. */ cwdAliases?: string[] branch: string | null model: string | null diff --git a/src/shared/protocol-version.ts b/src/shared/protocol-version.ts index 5b989c2cc3f..1061d91aa6e 100644 --- a/src/shared/protocol-version.ts +++ b/src/shared/protocol-version.ts @@ -76,6 +76,7 @@ export { STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY } from './structured-agent-session-surface-capabilities' diff --git a/src/shared/structured-agent-session-surface-capabilities.ts b/src/shared/structured-agent-session-surface-capabilities.ts index 06e5ba4dae7..b4f480db51e 100644 --- a/src/shared/structured-agent-session-surface-capabilities.ts +++ b/src/shared/structured-agent-session-surface-capabilities.ts @@ -21,6 +21,9 @@ export const STRUCTURED_AGENT_SESSION_RENAME_RUNTIME_CAPABILITY = // Negotiate resumeFrom before sending: old strict create schemas reject it, while unproven acceptance could silently start a blank chat. export const STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY = 'agent-session.structured.resume-history.v1' as const +// Alias-dependent actions need actual-source selected-path correspondence, absent on v1 hosts. +export const STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY = + 'agent-session.structured.resume-linked-cwd.v1' as const // Gate savedOnly because old strict catalog params reject it; otherwise preload only agents with saved picks. export const AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY = @@ -48,6 +51,7 @@ export const STRUCTURED_AGENT_SESSION_SURFACE_RUNTIME_CAPABILITIES = [ STRUCTURED_AGENT_SESSION_REGISTERED_AGENTS_RUNTIME_CAPABILITY, PI_STRUCTURED_DIALOGS_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY, + STRUCTURED_AGENT_SESSION_RESUME_LINKED_CWD_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_CLI_SOURCE_RUNTIME_CAPABILITY, AGENT_SESSION_MODEL_CATALOG_SAVED_ONLY_RUNTIME_CAPABILITY, STRUCTURED_AGENT_SESSION_AVAILABILITY_RUNTIME_CAPABILITY,