mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 16:47:34 +00:00
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:
@@ -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
|
||||
}
|
||||
|
||||
+222
-2
@@ -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 {
|
||||
|
||||
+231
@@ -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'), [
|
||||
{
|
||||
|
||||
+76
-3
@@ -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)
|
||||
|
||||
+34
-23
@@ -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([
|
||||
|
||||
+40
-1
@@ -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", () => {
|
||||
|
||||
+15
-4
@@ -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)
|
||||
}
|
||||
})
|
||||
+5
-5
@@ -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[],
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user