fix(agent-history): match non-ASCII workspace paths to Claude sessions (#10841)

This commit is contained in:
Neil
2026-07-26 22:39:32 -07:00
committed by GitHub
parent 58ef46d252
commit a1a78da878
10 changed files with 300 additions and 72 deletions
@@ -0,0 +1,130 @@
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { scanAiVaultSessions } from './session-scanner'
import { resetProjectDirCwdCacheForTests } from './session-scanner-scope-discovery'
import { isolatedScanRoots, jsonLines } from './session-scanner-test-fixtures'
let tempRoots: string[] = []
afterEach(async () => {
await Promise.all(tempRoots.map((root) => rm(root, { recursive: true, force: true })))
tempRoots = []
resetProjectDirCwdCacheForTests()
})
// Claude's own rule, deliberately spelled out rather than imported from the
// production encoder: these tests exist to catch Orca drifting away from how
// Claude actually names its directories, which a shared helper would hide.
// Claude maps every non-alphanumeric character of the raw cwd to '-', keeping
// case, and records the cwd it was launched with (NFC on macOS).
function claudeProjectDirName(cwd: string): string {
return cwd.normalize('NFC').replace(/[^a-zA-Z0-9]/g, '-')
}
describe('scanAiVaultSessions — non-ASCII scope paths', () => {
it('lists Claude sessions when the workspace path is NFD and the transcript is NFC', async () => {
// Regression for #10832: macOS hands Orca decomposed (NFD) workspace paths
// while Claude Code writes NFC in both the project dir name and the recorded
// cwd, so every workspace with non-ASCII path segments listed zero sessions.
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-nfc-scope-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const workspaceNfc = '/Users/ada/내 드라이브/한국농어촌공사'
const workspaceNfd = workspaceNfc.normalize('NFD')
expect(workspaceNfd).not.toBe(workspaceNfc)
const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspaceNfc))
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'korean-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'korean-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: workspaceNfc,
gitBranch: 'main',
message: { role: 'user', content: '안녕하세요' }
}
])
)
// A newer ASCII session plus limit:1 exhausts the recency cap, so the Korean
// session can only surface through scope discovery — the path under test.
await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'recent-session',
timestamp: '2026-06-01T10:00:00.000Z',
cwd: '/Users/ada/other',
gitBranch: 'main',
message: { role: 'user', content: 'newer' }
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'darwin',
limit: 1,
scopePaths: [workspaceNfd]
})
expect(result.sessions.map((session) => session.sessionId)).toContain('korean-session')
})
it('lists Claude sessions for a Windows workspace whose path has uppercase segments', async () => {
// Claude derives the dir name from the raw cwd, so it keeps 'C--Users-Ada-repo'.
// Encoding from the lowercased comparison key produced 'c--users-ada-repo',
// which never matched — scoped discovery was dead on Windows entirely.
const root = await mkdtemp(join(tmpdir(), 'orca-ai-vault-win-scope-'))
tempRoots.push(root)
const roots = isolatedScanRoots(root)
const workspace = 'C:\\Users\\Ada\\repo'
const projectDir = join(roots.claudeProjectsDir, claudeProjectDirName(workspace))
await mkdir(projectDir, { recursive: true })
await writeFile(
join(projectDir, 'windows-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'windows-session',
timestamp: '2026-05-01T10:00:00.000Z',
cwd: workspace,
gitBranch: 'main',
message: { role: 'user', content: 'hello' }
}
])
)
await mkdir(join(roots.claudeProjectsDir, '-Users-ada-other'), { recursive: true })
await writeFile(
join(roots.claudeProjectsDir, '-Users-ada-other', 'recent-session.jsonl'),
jsonLines([
{
type: 'user',
sessionId: 'recent-session',
timestamp: '2026-06-01T10:00:00.000Z',
cwd: '/Users/ada/other',
gitBranch: 'main',
message: { role: 'user', content: 'newer' }
}
])
)
const result = await scanAiVaultSessions({
...roots,
platform: 'win32',
limit: 1,
scopePaths: [workspace]
})
expect(result.sessions.map((session) => session.sessionId)).toContain('windows-session')
})
})
@@ -4,7 +4,7 @@ import { createInterface } from 'node:readline'
import { extname, join } from 'node:path'
import {
isPathInsideOrEqual,
normalizeRuntimePathForComparison
normalizeRuntimePathSeparators
} from '../../shared/cross-platform-path'
import type { AiVaultScanIssue } from '../../shared/ai-vault-types'
import { parseWslUncPath } from '../../shared/wsl-paths'
@@ -91,7 +91,9 @@ function claudeProjectScopePrefixes(scopePaths: readonly string[]): Set<string>
const prefixes = new Set<string>()
for (const scopePath of scopePaths) {
for (const candidate of scopePathCandidates(scopePath)) {
prefixes.add(encodeClaudeProjectPath(candidate))
for (const prefix of encodeClaudeProjectPaths(candidate)) {
prefixes.add(prefix)
}
}
}
return prefixes
@@ -102,8 +104,22 @@ function scopePathCandidates(scopePath: string): string[] {
return wslScopePath ? [scopePath, wslScopePath.linuxPath] : [scopePath]
}
/**
* Why: Claude derives the directory name from the raw cwd, so encoding from the
* comparison key would lowercase Windows paths and never match on disk. Encode
* the raw path, plus its NFC spelling, since macOS hands us NFD (#10832).
*/
function encodeClaudeProjectPaths(pathValue: string): string[] {
const raw = encodeClaudeProjectPath(pathValue)
const composed = encodeClaudeProjectPath(pathValue.normalize('NFC'))
return raw === composed ? [raw] : [raw, composed]
}
function encodeClaudeProjectPath(pathValue: string): string {
return normalizeRuntimePathForComparison(pathValue).replace(/[^a-zA-Z0-9]/g, '-')
const separated = normalizeRuntimePathSeparators(pathValue)
const trimmed =
separated === '/' || /^[A-Za-z]:\/$/.test(separated) ? separated : separated.replace(/\/+$/, '')
return trimmed.replace(/[^a-zA-Z0-9]/g, '-')
}
function isClaudeProjectDirInScope(projectDirName: string, scopePrefixes: ReadonlySet<string>) {
+1 -1
View File
@@ -39,7 +39,7 @@ function snapshotFrame(entries: [type: string, mtime: string, path: string][]):
.join('')}${SNAPSHOT_END}`
}
type ScheduleBatchFlush = (rootKey: string, root: WatchedRoot) => void
type ScheduleBatchFlush = (root: WatchedRoot) => void
type ScheduleBatchFlushMock = ReturnType<typeof vi.fn<ScheduleBatchFlush>>
function makeDeps(
+7 -5
View File
@@ -29,12 +29,14 @@ export type WatchedRoot = {
subscription: WatcherSubscription
listeners: Map<number, WebContents>
batch: DebouncedBatch
rootPath?: string
// Why: the real on-disk path. Never substitute the watcher's rootKey — that is
// a comparison key (case/Unicode folded) and would reach the renderer as a path.
rootPath: string
}
export type WslWatcherDeps = {
ignoreDirs: string[]
scheduleBatchFlush: (rootKey: string, root: WatchedRoot) => void
scheduleBatchFlush: (root: WatchedRoot) => void
watchedRoots: Map<string, WatchedRoot>
}
@@ -192,7 +194,7 @@ export async function createWslWatcher(
}
stopped = true
markOverflowWithoutUncStat(root)
deps.scheduleBatchFlush(rootKey, root)
deps.scheduleBatchFlush(root)
deps.watchedRoots.delete(rootKey)
}
@@ -208,7 +210,7 @@ export async function createWslWatcher(
if (events.length > 0) {
queueWatcherEvents(root.batch, events)
deps.scheduleBatchFlush(rootKey, root)
deps.scheduleBatchFlush(root)
}
}
@@ -227,7 +229,7 @@ export async function createWslWatcher(
if (streamBuffer.length > MAX_STREAM_BUFFER_CHARS) {
streamBuffer = ''
markOverflowWithoutUncStat(root)
deps.scheduleBatchFlush(rootKey, root)
deps.scheduleBatchFlush(root)
}
return
}
+3 -2
View File
@@ -118,7 +118,7 @@ describe('registerFilesystemWatcherHandlers', () => {
const heldReservations = Array.from({ length: MAX_PHYSICAL_WATCHER_CHILDREN }, () =>
reserveWatcherChild()
)
vi.mocked(createWslWatcher).mockImplementation(async () => {
vi.mocked(createWslWatcher).mockImplementation(async (_rootKey, worktreePath) => {
const release = reserveWatcherChild()
if (!release) {
throw new WatcherChildCapacityError()
@@ -126,7 +126,8 @@ describe('registerFilesystemWatcherHandlers', () => {
return {
subscription: { unsubscribe: vi.fn(async () => release()) },
listeners: new Map(),
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 }
batch: { events: [], overflowed: false, timer: null, firstEventAt: 0 },
rootPath: worktreePath
}
})
const sender = { isDestroyed: () => false, send: vi.fn(), once: vi.fn(), id: 1 }
+11 -11
View File
@@ -274,8 +274,8 @@ async function tryStatIsDirectory(filePath: string): Promise<boolean | undefined
// ── Flush and emit ───────────────────────────────────────────────────
function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
const rootPath = root.rootPath ?? rootKey
function emitOverflowPayload(root: WatchedRoot): void {
const { rootPath } = root
const payload: FsChangedPayload = {
worktreePath: rootPath,
events: [{ kind: 'overflow', absolutePath: rootPath }]
@@ -287,7 +287,7 @@ function emitOverflowPayload(rootKey: string, root: WatchedRoot): void {
}
}
async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
async function flushBatch(root: WatchedRoot): Promise<void> {
const overflowed = root.batch.overflowed
const rawEvents = root.batch.events.splice(0)
root.batch.overflowed = false
@@ -300,7 +300,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
if (overflowed || rawEvents.length > MAX_BATCHED_WATCHER_EVENTS) {
// Why: deletion storms can be too large to coalesce/stat per path; one overflow asks the renderer for the same conservative refresh.
emitOverflowPayload(rootKey, root)
emitOverflowPayload(root)
return
}
@@ -320,7 +320,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
)
const payload: FsChangedPayload = {
worktreePath: root.rootPath ?? rootKey,
worktreePath: root.rootPath,
events
}
@@ -331,7 +331,7 @@ async function flushBatch(rootKey: string, root: WatchedRoot): Promise<void> {
}
}
function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
function scheduleBatchFlush(root: WatchedRoot): void {
const now = Date.now()
if (root.batch.firstEventAt === 0) {
@@ -343,7 +343,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
void flushBatch(rootKey, root)
void flushBatch(root)
return
}
@@ -351,7 +351,7 @@ function scheduleBatchFlush(rootKey: string, root: WatchedRoot): void {
if (root.batch.timer) {
clearTimeout(root.batch.timer)
}
root.batch.timer = setTimeout(() => void flushBatch(rootKey, root), DEBOUNCE_TRAILING_MS)
root.batch.timer = setTimeout(() => void flushBatch(root), DEBOUNCE_TRAILING_MS)
}
// ── Watcher creation ─────────────────────────────────────────────────
@@ -380,7 +380,7 @@ async function createWatcher(
const markWatcherInterrupted = (): void => {
root.batch.overflowed = true
scheduleBatchFlush(rootKey, root)
scheduleBatchFlush(root)
}
// Why: fork the watcher process (issue #7547 — watcher.node teardown races crash the host); onInterruption marks overflow to refresh past the gap.
@@ -390,7 +390,7 @@ async function createWatcher(
if (err) {
// Why: treat watcher errors as overflow so the renderer conservatively refreshes rather than trusting possibly-invalid caches (§7.2, §7.3).
console.error(`[filesystem-watcher] error for ${rootKey}:`, err)
emitOverflowPayload(rootKey, root)
emitOverflowPayload(root)
// Why: after an error the native subscription may be invalid (deleted root); tear down the dead watcher so it doesn't dangle (§7.3).
if (root.batch.timer) {
clearTimeout(root.batch.timer)
@@ -406,7 +406,7 @@ async function createWatcher(
}
queueWatcherEvents(root.batch, events)
scheduleBatchFlush(rootKey, root)
scheduleBatchFlush(root)
},
watcherOptions,
{
+9
View File
@@ -39,4 +39,13 @@ describe('getRelativePathInsideRoot', () => {
it('keeps POSIX path checks case-sensitive', () => {
expect(getRelativePathInsideRoot('/Repo/Docs/Plan.md', '/repo')).toBeNull()
})
it('matches a decomposed root against a composed file path', () => {
// Regression for #10832: a local copy of this comparison folded case but not
// Unicode, so macOS NFD roots missed their own NFC files entirely.
const composed = '/Users/ada/프로젝트'
expect(getRelativePathInsideRoot(`${composed}/Docs/Plan.md`, composed.normalize('NFD'))).toBe(
'Docs/Plan.md'
)
})
})
+5 -43
View File
@@ -1,3 +1,5 @@
import { relativePathInsideRoot } from '../../../shared/cross-platform-path'
function stripTrailingSeparators(path: string): string {
return path.replace(/[\\/]+$/, '')
}
@@ -14,26 +16,6 @@ export function normalizeRelativePath(path: string): string {
return stripLeadingSeparators(path).replace(/[\\/]+/g, '/')
}
function normalizeAbsolutePath(path: string): string {
const isUncPath = /^[\\/]{2}[^\\/]/.test(path)
const normalized = path.replace(/[\\/]+/g, '/')
return isUncPath && !normalized.startsWith('//') ? `/${normalized}` : normalized
}
function stripTrailingAbsoluteSeparators(path: string): string {
if (path === '/') {
return path
}
if (/^[A-Za-z]:\/$/.test(path)) {
return path
}
return path.replace(/\/+$/, '')
}
function isCaseInsensitiveAbsolutePath(path: string): boolean {
return /^[A-Za-z]:(?:\/|$)/.test(path) || path.startsWith('//')
}
export function getRelativePathInsideRoot(
filePath: string,
rootPath: string | null
@@ -41,29 +23,9 @@ export function getRelativePathInsideRoot(
if (!rootPath) {
return null
}
const normalizedFilePath = normalizeAbsolutePath(filePath)
const normalizedRoot = stripTrailingAbsoluteSeparators(normalizeAbsolutePath(rootPath))
const comparisonFilePath = isCaseInsensitiveAbsolutePath(normalizedFilePath)
? normalizedFilePath.toLowerCase()
: normalizedFilePath
const comparisonRoot = isCaseInsensitiveAbsolutePath(normalizedRoot)
? normalizedRoot.toLowerCase()
: normalizedRoot
if (comparisonFilePath === comparisonRoot) {
return ''
}
// Why: POSIX and Windows drive roots already include their trailing separator.
const isRootPath = normalizedRoot === '/' || /^[A-Za-z]:\/$/.test(normalizedRoot)
const rootPrefix = isRootPath ? comparisonRoot : `${comparisonRoot}/`
if (!comparisonFilePath.startsWith(rootPrefix)) {
return null
}
const sliceStart = isRootPath ? normalizedRoot.length : normalizedRoot.length + 1
return normalizeRelativePath(normalizedFilePath.slice(sliceStart))
// Why: delegate so renderer containment folds Unicode the same way the rest of
// the app does — a local copy silently missed NFD-vs-NFC matches (#10832).
return relativePathInsideRoot(rootPath, filePath)
}
export function basename(path: string): string {
+59
View File
@@ -70,6 +70,65 @@ describe('cross-platform path containment', () => {
).toBe('line\nbreak')
})
it('matches macOS NFD paths against agent-recorded NFC paths', () => {
// Regression for #10832: macOS file pickers hand Orca decomposed (NFD) paths
// while Claude Code records cwd and names its project dirs in NFC, so a
// non-ASCII workspace never matched its own sessions.
const nfc = '/Users/ada/내 드라이브/프로젝트'
const nfd = nfc.normalize('NFD')
expect(nfd).not.toBe(nfc)
expect(normalizeRuntimePathForComparison(nfd)).toBe(normalizeRuntimePathForComparison(nfc))
expect(isPathInsideOrEqual(nfd, `${nfc}/src`)).toBe(true)
expect(isPathInsideOrEqual(nfc, `${nfd}/src`)).toBe(true)
// WSL UNC keys return before the trailing fold, so they need NFC too.
expect(
normalizeRuntimePathForComparison(
`\\\\wsl$\\Ubuntu\\home\\ada\\${'프로젝트'.normalize('NFD')}`
)
).toBe(normalizeRuntimePathForComparison(`\\\\wsl.localhost\\Ubuntu\\home\\ada\\프로젝트`))
})
it('returns a byte-exact suffix when comparison folding changes length', () => {
// Comparison folding (NFC, case) is not length-preserving, so slicing the raw
// candidate by the folded root's length would cut mid-character and fabricate
// a path — callers rejoin this suffix and hit the filesystem with it.
const nfc = '/Users/ada/프로젝트'
const nfd = nfc.normalize('NFD')
for (const root of [nfc, nfd]) {
for (const candidate of [nfc, nfd]) {
expect(relativePathInsideRoot(root, `${candidate}/src/index.ts`)).toBe('src/index.ts')
}
}
// Pre-existing over-slice: toLowerCase expands U+0130 to two UTF-16 units.
expect(relativePathInsideRoot('C:\\İş', 'C:\\İş\\src\\a.ts')).toBe('src/a.ts')
// U+212A KELVIN SIGN folds to 'K', so the root and candidate must agree on
// Windows-ness or their segment counts desync and the suffix comes back ''.
expect(relativePathInsideRoot('\u212A:/a\\b', '\u212A:/a\\b/c')).toBe(
relativePathInsideRoot('K:/a\\b', 'K:/a\\b/c')
)
// Astral characters must not be cut mid-surrogate-pair.
expect(relativePathInsideRoot('/repo/🚀app', '/repo/🚀app/src/🎉file.ts')).toBe('src/🎉file.ts')
// A UNC-shaped candidate under POSIX root '/' used to yield a leading slash,
// which is not a relative path.
expect(relativePathInsideRoot('/', '//server/share/x')).toBe('server/share/x')
// WSL suffixes must stay decomposed: they name files on a Linux filesystem,
// where NFD and NFC are distinct entries.
const decomposed = '프로젝트'.normalize('NFD')
expect(
relativePathInsideRoot(
'\\\\wsl$\\Ubuntu\\home\\ada\\repo',
`\\\\wsl.localhost\\Ubuntu\\home\\ada\\repo\\${decomposed}\\a.ts`
)
).toBe(`${decomposed}/a.ts`)
})
it('resolves POSIX relative paths without using the process cwd', () => {
expect(resolveRuntimePath('/repos/app/repo', '../worktrees/feature')).toBe(
'/repos/app/worktrees/feature'
+56 -7
View File
@@ -1,3 +1,5 @@
const SLASH_CHAR_CODE = '/'.charCodeAt(0)
export function isWindowsAbsolutePathLike(value: string): boolean {
return /^[A-Za-z]:[\\/]/.test(value) || value.startsWith('\\\\') || value.startsWith('//')
}
@@ -10,7 +12,19 @@ export function normalizeRuntimePathSeparators(value: string): string {
return normalized
}
export function normalizeRuntimePathForComparison(value: string): string {
/**
* Comparison key only — never return this as, or splice it into, a real path.
*
* Why NFC: macOS file pickers and on-disk names yield NFD, while agents such as
* Claude Code record cwd and encode their project directory names in NFC. Both
* spell the same file, so a non-ASCII workspace otherwise never matches its own
* sessions (#10832). Folding here knowingly treats canonically equivalent names
* as one, which is exact on APFS but permissive on byte-exact Linux/SSH hosts —
* an acceptable trade, since only comparison keys are affected.
*/
export function normalizeRuntimePathForComparison(rawValue: string): string {
// Normalize before any folding so the WSL alias branch below is covered too.
const value = rawValue.normalize('NFC')
const isWindowsPath = isWindowsAbsolutePathLike(value)
// Why: backslash is a valid POSIX filename character; fold it only when the
// path itself proves Windows drive/UNC semantics.
@@ -68,8 +82,12 @@ export function isPathInsideOrEqual(rootPath: string, candidatePath: string): bo
}
export function relativePathInsideRoot(rootPath: string, candidatePath: string): string | null {
// Why: decide Windows-ness on the same NFC form the comparison key uses, or the
// two disagree (U+212A folds to 'K', making only one side a drive path) and the
// segment counts desync. Only the branch test sees NFC — the sliced string stays
// raw so the returned suffix remains byte-exact.
const normalizedCandidate = trimRuntimePathTrailingSlash(
isWindowsAbsolutePathLike(candidatePath)
isWindowsAbsolutePathLike(candidatePath.normalize('NFC'))
? normalizeRuntimePathSeparators(candidatePath)
: candidatePath.replace(/\/+/g, '/')
)
@@ -84,11 +102,42 @@ export function relativePathInsideRoot(rootPath: string, candidatePath: string):
if (!comparisonCandidate.startsWith(comparisonPrefix)) {
return null
}
// WSL comparison keys fold the UNC alias but preserve Linux path casing, so
// their suffix is both aligned across aliases and safe to return directly.
return comparisonRoot.startsWith('//wsl/')
? comparisonCandidate.slice(comparisonPrefix.length)
: normalizedCandidate.slice(comparisonPrefix.length)
return sliceCandidatePastRootSegments(comparisonRoot, normalizedCandidate)
}
/**
* Why: skip whole root segments rather than a character count. Comparison
* folding (NFC, case, UNC alias) changes length, so a folded-prefix length would
* cut the raw candidate mid-character and fabricate a path; segment positions
* survive every fold and keep the suffix byte-exact. Scanning rather than
* splitting keeps watcher event storms allocation-free.
*/
function sliceCandidatePastRootSegments(root: string, candidate: string): string {
let remainingRootSegments = 0
let inRootSegment = false
for (let index = 0; index < root.length; index++) {
if (root.charCodeAt(index) === SLASH_CHAR_CODE) {
inRootSegment = false
} else if (!inRootSegment) {
inRootSegment = true
remainingRootSegments++
}
}
let inSegment = false
for (let index = 0; index < candidate.length; index++) {
if (candidate.charCodeAt(index) === SLASH_CHAR_CODE) {
inSegment = false
continue
}
if (!inSegment) {
inSegment = true
if (remainingRootSegments-- === 0) {
return candidate.slice(index)
}
}
}
return ''
}
function trimRuntimePathTrailingSlash(value: string): string {