Restore Session History resume for symlinked workspaces (#35052)

* Fix Session History resume for symlinked workspaces

* Bound history alias derivation and cover resume consumers

* Reuse equal alias values across history publications

* fix(history): verify adopted source directory before commit

* fix: preserve UTF-8 paths in directory admission probe

* fix: bypass transcript preparation for non-adopted starts
This commit is contained in:
Brennan Benson
2026-10-29 13:17:40 -07:00
committed by GitHub
parent e08cb10cc6
commit 920fa6cdbc
35 changed files with 1704 additions and 165 deletions
@@ -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<typeof useMobileAgentHistoryState> | 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]) {
@@ -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' },
@@ -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<string, string>,
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<string, string> = { [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)
}
}
}
}
)
@@ -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<object>()
get ownedCount(): number {
return this.owned.size
}
async matches(workspaceCwd: string, sourceCwd: string, signal?: AbortSignal): Promise<boolean> {
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()
@@ -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<void> {
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<void> {
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
)
}
@@ -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 () => {
@@ -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
@@ -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<JournalReplacementItem[] | null> {
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)
@@ -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
}
@@ -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<object>()),
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({
@@ -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 {
@@ -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<typeof ChildProcessModule>()),
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)
}
)
@@ -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'), [
{
@@ -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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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<typeof AiVaultSessionVirtualList>,
'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(<AiVaultPanel workspace={bound ? { worktreeId: folderId } : undefined} />)
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)
}
)
@@ -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(
@@ -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
})
}
@@ -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',
@@ -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)
@@ -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<StructuredAgentLaunchSettlement>((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<StructuredAgentLaunchSettlement>((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([
@@ -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", () => {
@@ -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
})
}
@@ -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<AiVaultSession['agent']>(['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<AiVaultSession['agent']>(['claude', 'grok', 'omp', 'opencode', 'opencode2', 'pi'])(
'refuses a %s row whose conversation was recorded in another workspace',
(agent) => {
@@ -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' }
}
@@ -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(() =>
@@ -80,6 +80,40 @@ function makeRepo(overrides: Partial<Repo> = {}): 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()
@@ -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<Record<string, unknown>>()),
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<Record<string, unknown>>()),
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()
})
@@ -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)
}
@@ -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<Record<string, unknown>>()),
readLocalRuntimeCapabilitiesOrUnknown: () => [
STRUCTURED_AGENT_SESSION_RESUME_HISTORY_RUNTIME_CAPABILITY
]
readLocalRuntimeCapabilitiesOrUnknown: readLocalCapabilities
}))
vi.mock('@/lib/agent-session-launch-plan', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
@@ -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
)
}
)
})
@@ -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, 'agent' | 'cwd' | 'cwdAliases' | 'executionHostId' | 'filePath'>
): 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])]
}
}
@@ -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)
}
})
@@ -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<T extends ProjectCandidate>(
export function matchAiVaultSessionCwdCandidates<T extends CwdCandidate>(
session: Pick<AiVaultSession, 'cwdAliases' | 'filePath'>,
cwd: string,
candidates: readonly T[],
+19 -17
View File
@@ -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 {
+1 -1
View File
@@ -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
+1
View File
@@ -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'
@@ -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,