import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { TeamTranscriptProjectResolver } from '../../../../src/main/services/team/TeamTranscriptProjectResolver'; import { encodePath, setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import type { TeamConfig } from '../../../../src/shared/types/team'; describe('TeamTranscriptProjectResolver', () => { let tmpDir: string | null = null; afterEach(async () => { setClaudeBasePathOverride(null); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; } }); async function setupClaudeRoot(): Promise { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-transcript-project-resolver-')); setClaudeBasePathOverride(tmpDir); await fs.mkdir(path.join(tmpDir, 'teams'), { recursive: true }); await fs.mkdir(path.join(tmpDir, 'projects'), { recursive: true }); return tmpDir; } async function writeTeamConfig(teamName: string, config: TeamConfig): Promise { const teamDir = path.join(tmpDir!, 'teams', teamName); await fs.mkdir(teamDir, { recursive: true }); await fs.writeFile(path.join(teamDir, 'config.json'), JSON.stringify(config, null, 2), 'utf8'); } async function readTeamConfig(teamName: string): Promise { const raw = await fs.readFile(path.join(tmpDir!, 'teams', teamName, 'config.json'), 'utf8'); return JSON.parse(raw) as TeamConfig; } async function createSessionFile( projectPath: string, sessionId: string, cwd: string = projectPath ): Promise<{ projectDir: string; jsonlPath: string }> { const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); await fs.writeFile( jsonlPath, `${JSON.stringify({ type: 'assistant', timestamp: '2026-04-18T10:00:00.000Z', cwd, message: { role: 'assistant', content: [{ type: 'text', text: 'Resolver probe output' }], }, })}\n`, 'utf8' ); return { projectDir, jsonlPath }; } async function createSessionFileInProjectDir( projectDirName: string, sessionId: string, cwd: string ): Promise<{ projectDir: string; jsonlPath: string }> { const projectDir = path.join(tmpDir!, 'projects', projectDirName); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); await fs.writeFile( jsonlPath, `${JSON.stringify({ type: 'assistant', timestamp: '2026-04-18T10:00:00.000Z', cwd, message: { role: 'assistant', content: [{ type: 'text', text: 'Resolver probe output' }], }, })}\n`, 'utf8' ); return { projectDir, jsonlPath }; } async function createTeamAwareSessionFile( projectPath: string, sessionId: string, teamName: string, mode: 'text' | 'nested' ): Promise<{ projectDir: string; jsonlPath: string }> { const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`); const lines = mode === 'text' ? [ { type: 'user', timestamp: '2026-04-18T10:00:00.000Z', cwd: projectPath, message: { role: 'user', content: [ { type: 'text', text: `Current durable team context:\n- Team name: ${teamName}\n- You are the live team lead "team-lead"`, }, ], }, }, ] : [ { type: 'assistant', timestamp: '2026-04-18T10:00:00.000Z', cwd: projectPath, message: { role: 'assistant', content: [ { type: 'tool_use', id: 'call_probe', name: 'mcp__agent-teams__task_create_from_message', input: { teamName, subject: 'Probe task', }, }, ], }, }, ]; await fs.writeFile( jsonlPath, `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, 'utf8' ); return { projectDir, jsonlPath }; } it('uses snapshot-capable config readers for resolver observations', async () => { await setupClaudeRoot(); const { projectDir } = await createSessionFile('/repo/current', 'lead-session-1'); const getConfig = vi.fn(async () => { throw new Error('verified config read should not be used for transcript observations'); }); const getConfigSnapshot = vi.fn(async () => ({ name: 'My Team', projectPath: '/repo/current', leadSessionId: 'lead-session-1', members: [{ name: 'team-lead', agentType: 'team-lead' }], })); const resolver = new TeamTranscriptProjectResolver({ getConfig, getConfigSnapshot, }); const context = await resolver.getContext('my-team'); expect(context?.projectDir).toBe(projectDir); expect(getConfigSnapshot).toHaveBeenCalledWith('my-team'); expect(getConfig).not.toHaveBeenCalled(); }); it('resolves live base context from cheap projectPath evidence without session discovery', async () => { await setupClaudeRoot(); const teamName = 'live-base-team'; const projectPath = '/Users/test/live-base'; const projectDir = path.join(tmpDir!, 'projects', encodePath(projectPath)); await fs.mkdir(projectDir, { recursive: true }); const getConfig = vi.fn(async () => { throw new Error('verified config read should not be used for live base context'); }); const getConfigSnapshot = vi.fn(async () => ({ name: teamName, projectPath, leadSessionId: 'lead-session', members: [{ name: 'team-lead', agentType: 'team-lead' }], })); const resolver = new TeamTranscriptProjectResolver({ getConfig, getConfigSnapshot, }); const context = await resolver.getLiveBaseContext(teamName, { forceRefresh: true }); expect(context?.projectDir).toBe(projectDir); expect(context?.config.projectPath).toBe(projectPath); expect(getConfigSnapshot).toHaveBeenCalledWith(teamName); expect(getConfig).not.toHaveBeenCalled(); }); it('repairs stale projectPath when exact leadSessionId exists only in the renamed project', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const leadSessionId = 'lead-1'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); const repaired = await createSessionFile(repairedProjectPath, leadSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context).not.toBeNull(); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); expect(persisted.projectPath).toBe(repairedProjectPath); expect(persisted.projectPathHistory).toEqual(expect.arrayContaining([staleProjectPath])); }); it('keeps the current projectPath when it already contains the exact session', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const currentProjectPath = '/Users/test/hookplex'; const alternateProjectPath = '/Users/test/plugin-kit-ai'; const leadSessionId = 'lead-1'; const current = await createSessionFile(currentProjectPath, leadSessionId); await createSessionFile(alternateProjectPath, leadSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: currentProjectPath, projectPathHistory: [alternateProjectPath], leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: alternateProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context?.projectDir).toBe(current.projectDir); expect(context?.config.projectPath).toBe(currentProjectPath); expect(persisted.projectPath).toBe(currentProjectPath); expect(persisted.projectPathHistory).toEqual([alternateProjectPath]); }); it('falls back to exact sessionHistory ids when leadSessionId file is missing', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const historicalSessionId = 'lead-old'; await fs.mkdir(path.join(tmpDir!, 'projects', encodePath(staleProjectPath)), { recursive: true, }); const repaired = await createSessionFile(repairedProjectPath, historicalSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, leadSessionId: 'lead-missing', sessionHistory: [historicalSessionId], members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); expect(persisted.projectPath).toBe(repairedProjectPath); }); it('prefers the newest sessionHistory match when leadSessionId is missing', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const olderSessionId = 'lead-old'; const newerSessionId = 'lead-new'; await createSessionFile(staleProjectPath, olderSessionId); const repaired = await createSessionFile(repairedProjectPath, newerSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, leadSessionId: 'lead-missing', sessionHistory: [olderSessionId, newerSessionId], members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); }); it('does not let an old sessionHistory match block repair when the current leadSessionId exists elsewhere', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const leadSessionId = 'lead-current'; const historicalSessionId = 'lead-old'; await createSessionFile(staleProjectPath, historicalSessionId); const repaired = await createSessionFile(repairedProjectPath, leadSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, leadSessionId, sessionHistory: [historicalSessionId], members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); expect(persisted.projectPath).toBe(repairedProjectPath); }); it('picks the best exact session match across dir variants for the same projectPath', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const projectPath = '/Users/test/plugin_kit_ai'; const staleSessionId = 'lead-old'; const currentSessionId = 'lead-current'; await createSessionFile(projectPath, staleSessionId); const repaired = await createSessionFileInProjectDir( encodePath(projectPath).replace(/_/g, '-'), currentSessionId, projectPath ); await writeTeamConfig(teamName, { name: 'My Team', projectPath, leadSessionId: currentSessionId, sessionHistory: [staleSessionId], members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); expect(context?.projectDir).toBe(repaired.projectDir); }); it('does not self-heal when an alternate configured match is not unique across projects scan', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const configuredProjectPath = '/Users/test/plugin-kit-ai'; const duplicateProjectPath = '/Users/test/plugin-kit-ai-copy'; const leadSessionId = 'lead-1'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); await createSessionFile(configuredProjectPath, leadSessionId); await createSessionFile(duplicateProjectPath, leadSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, projectPathHistory: [configuredProjectPath], leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: configuredProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const warnSpy = vi.mocked(console.warn); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context?.projectDir).toBe(staleProjectDir); expect(context?.config.projectPath).toBe(staleProjectPath); expect(persisted.projectPath).toBe(staleProjectPath); expect(warnSpy.mock.calls).toEqual( expect.arrayContaining([ expect.arrayContaining([ expect.stringContaining( 'Transcript project resolution ambiguous across exact-session candidates' ), ]), ]) ); warnSpy.mockClear(); }); it('does not self-heal when full scan finds multiple equally valid session matches', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const staleProjectPath = '/Users/test/hookplex'; const leadSessionId = 'lead-1'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); await createSessionFile('/Users/test/plugin-kit-ai', leadSessionId); await createSessionFile('/Users/test/plugin-kit-ai-copy', leadSessionId); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead' }], }); const resolver = new TeamTranscriptProjectResolver(); const warnSpy = vi.mocked(console.warn); const context = await resolver.getContext(teamName); const persisted = await readTeamConfig(teamName); expect(context?.projectDir).toBe(staleProjectDir); expect(context?.config.projectPath).toBe(staleProjectPath); expect(persisted.projectPath).toBe(staleProjectPath); expect(warnSpy.mock.calls).toEqual( expect.arrayContaining([ expect.arrayContaining([ expect.stringContaining( 'Transcript project resolution ambiguous across exact-session candidates' ), ]), ]) ); warnSpy.mockClear(); }); it('falls back to an existing alternate dir candidate when no session ids are known yet', async () => { await setupClaudeRoot(); const teamName = 'my-team'; const projectPath = '/Users/test/plugin_kit_ai'; const alternateDir = encodePath(projectPath).replace(/_/g, '-'); const fallback = await createSessionFileInProjectDir(alternateDir, 'lead-1', projectPath); await writeTeamConfig(teamName, { name: 'My Team', projectPath, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: projectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); expect(context?.projectDir).toBe(fallback.projectDir); expect(context?.config.projectPath).toBe(projectPath); }); it('prefers a later candidate when the transcript text explicitly names the team and the stale project dir still exists', async () => { await setupClaudeRoot(); const teamName = 'vector-room-55555551'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); const repaired = await createTeamAwareSessionFile( repairedProjectPath, 'lead-1', teamName, 'text' ); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); }); it('recognizes nested tool input teamName during no-session fallback', async () => { await setupClaudeRoot(); const teamName = 'vector-room-55555551'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); const repaired = await createTeamAwareSessionFile( repairedProjectPath, 'lead-1', teamName, 'nested' ); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const context = await resolver.getContext(teamName); expect(context?.projectDir).toBe(repaired.projectDir); expect(context?.config.projectPath).toBe(repairedProjectPath); }); it('refreshes team affinity cache when a transcript file changes', async () => { await setupClaudeRoot(); const teamName = 'vector-room-55555552'; const staleProjectPath = '/Users/test/hookplex'; const repairedProjectPath = '/Users/test/plugin-kit-ai'; const staleProjectDir = path.join(tmpDir!, 'projects', encodePath(staleProjectPath)); await fs.mkdir(staleProjectDir, { recursive: true }); const repaired = await createTeamAwareSessionFile( repairedProjectPath, 'lead-1', teamName, 'text' ); await writeTeamConfig(teamName, { name: 'My Team', projectPath: staleProjectPath, members: [{ name: 'team-lead', agentType: 'team-lead', cwd: repairedProjectPath }], }); const resolver = new TeamTranscriptProjectResolver(); const firstContext = await resolver.getContext(teamName, { forceRefresh: true }); expect(firstContext?.projectDir).toBe(repaired.projectDir); await fs.writeFile( repaired.jsonlPath, `${JSON.stringify({ type: 'assistant', timestamp: '2026-04-18T10:01:00.000Z', cwd: repairedProjectPath, message: { role: 'assistant', content: [{ type: 'text', text: 'Resolver probe output without team context' }], }, })}\n`, 'utf8' ); const updatedAt = new Date(Date.now() + 5_000); await fs.utimes(repaired.jsonlPath, updatedAt, updatedAt); const secondContext = await resolver.getContext(teamName, { forceRefresh: true }); expect(secondContext?.projectDir).toBe(staleProjectDir); }); it('bounds root session discovery by team lifecycle in fast preview context', async () => { await setupClaudeRoot(); const teamName = 'fast-preview-team'; const projectPath = '/Users/test/fast-preview'; const createdAt = Date.parse('2026-04-18T12:00:00.000Z'); const leadSessionId = 'lead-fast'; const lead = await createTeamAwareSessionFile(projectPath, leadSessionId, teamName, 'text'); const recent = await createTeamAwareSessionFile( projectPath, 'recent-member-session', teamName, 'text' ); const old = await createTeamAwareSessionFile( projectPath, 'old-member-session', teamName, 'text' ); await fs.utimes(lead.jsonlPath, new Date(createdAt + 60_000), new Date(createdAt + 60_000)); await fs.utimes( recent.jsonlPath, new Date(createdAt + 5 * 60_000), new Date(createdAt + 5 * 60_000) ); await fs.utimes( old.jsonlPath, new Date(createdAt - 25 * 60 * 60_000), new Date(createdAt - 25 * 60 * 60_000) ); await writeTeamConfig(teamName, { name: 'Fast Preview Team', createdAt, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead', joinedAt: createdAt, cwd: projectPath }, { name: 'alice', agentType: 'general-purpose', joinedAt: createdAt + 5 * 60_000 }, ], } as TeamConfig); const resolver = new TeamTranscriptProjectResolver(); const fastContext = await resolver.getContext(teamName, { forceRefresh: true, includeTeamSubagentSessionDiscovery: false, }); const fullContext = await resolver.getContext(teamName, { forceRefresh: true }); expect(fastContext?.projectDir).toBe(lead.projectDir); expect(fastContext?.sessionIds).toEqual(expect.arrayContaining([leadSessionId])); expect(fastContext?.sessionIds).toContain('recent-member-session'); expect(fastContext?.sessionIds).not.toContain('old-member-session'); expect(fullContext?.sessionIds).toContain('old-member-session'); }); // Regression for the launch hot path: non-matching transcripts must not be // re-streamed + re-parsed on every bootstrap poll. A negative verdict decided from // a FULL head window (>= 40 inspected lines) is durable while the file only grows, // because an append-only transcript's head is immutable. Observed via the private // affinity cache: the durable branch returns WITHOUT re-caching, so the cached size // stays at the first scan's size (a re-scan would update it to the grown size). type AffinityCacheEntry = { mtimeMs: number; size: number; belongsToTeam: boolean; headWindowFull: boolean; }; type HeadMetadataCacheEntry = { mtimeMs: number; size: number; inspectedLineCount: number; lines: unknown[]; }; type ResolverProbe = { fileBelongsToTeam: ( filePath: string, teamName: string, precomputedStat?: { mtimeMs: number; size: number; isFile: () => boolean } ) => Promise; buildTeamAffinityFileCacheKey: (filePath: string, normalizedTeam: string) => string; teamAffinityFileCache: Map; teamAffinityHeadMetadataCache: Map; }; it('caches a full-head-window negative and stops re-scanning a growing non-matching transcript', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'absent-team'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/neg-durable')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'unrelated.jsonl'); const mkLine = (i: number) => JSON.stringify({ type: 'user', message: { role: 'user', content: `unrelated line ${i}` } }); // 45 non-empty lines, none mentioning the team -> full head window (40) inspected. await fs.writeFile( jsonlPath, `${Array.from({ length: 45 }, (_, i) => mkLine(i)).join('\n')}\n`, 'utf8' ); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase()); const first = resolver.teamAffinityFileCache.get(key); expect(first?.belongsToTeam).toBe(false); expect(first?.headWindowFull).toBe(true); const sizeAfterFirst = first!.size; // Append-only growth: size grows, mtime changes, but the inspected head is fixed. await fs.appendFile(jsonlPath, `${mkLine(100)}\n`); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); // Durable negative: the cache entry was NOT re-written (no re-scan), so its size // is still the original, smaller size. expect(resolver.teamAffinityFileCache.get(key)?.size).toBe(sizeAfterFirst); }); // Correctness guard: a SHORT-file negative (head window not yet full) is NOT durable // and must be re-scanned on growth, so a team mention that lands inside the first 40 // lines is still detected (the verdict flips to true). it('re-scans a short-file negative on growth and flips to true when the head gains a team mention', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'team-x'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/short-neg')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'short.jsonl'); // Only 3 lines, none mentioning the team -> partial head window (not durable). await fs.writeFile( jsonlPath, `${[0, 1, 2] .map((i) => JSON.stringify({ type: 'user', message: { role: 'user', content: `hi ${i}` } })) .join('\n')}\n`, 'utf8' ); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team.toLowerCase()); const first = resolver.teamAffinityFileCache.get(key); expect(first?.headWindowFull).toBe(false); const sizeAfterFirst = first!.size; // Append a line whose text content mentions the team (still within the first 40 lines). await fs.appendFile( jsonlPath, `${JSON.stringify({ type: 'user', message: { role: 'user', content: [ { type: 'text', text: `Current durable team context:\n- Team name: ${team}` }, ], }, })}\n` ); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); // re-scanned -> flips const second = resolver.teamAffinityFileCache.get(key); expect(second?.belongsToTeam).toBe(true); expect(second!.size).toBeGreaterThan(sizeAfterFirst); // re-scanned + re-cached }); // Regression: when the caller already statted the file (the mtime-window filter in // collectRootJsonlSessionIds), fileBelongsToTeam must reuse that stat rather than // issuing a second fs.stat of the same file. Proven without mocking fs: a precomputed // stat with a deliberately distinct size/mtime must be the one recorded in the cache. it('reuses a caller-supplied stat instead of re-statting the file', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'absent-team'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/precomp')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'f.jsonl'); await fs.writeFile( jsonlPath, `${Array.from({ length: 45 }, (_, i) => JSON.stringify({ type: 'user', message: { role: 'user', content: `x ${i}` } }) ).join('\n')}\n`, 'utf8' ); // Distinct sentinel values the real file does not have. const precomputedStat = { mtimeMs: 123_456, size: 999_999, isFile: () => true }; expect(await resolver.fileBelongsToTeam(jsonlPath, team, precomputedStat)).toBe(false); const key = resolver.buildTeamAffinityFileCacheKey(jsonlPath, team); const entry = resolver.teamAffinityFileCache.get(key); expect(entry?.size).toBe(999_999); // cache recorded the precomputed stat -> no re-stat expect(entry?.mtimeMs).toBe(123_456); }); it('reuses parsed head metadata across different team lookups for the same file signature', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/head-cache')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'shared.jsonl'); await fs.writeFile( jsonlPath, [ teamTextLine('alpha-team'), JSON.stringify({ type: 'assistant', message: { role: 'assistant', content: [{ type: 'tool_use', input: { teamName: 'beta-team' } }], }, }), ].join('\n') + '\n', 'utf8' ); const fileStat = await fs.stat(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'alpha-team', fileStat)).toBe(true); await fs.unlink(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'beta-team', fileStat)).toBe(true); expect(await resolver.fileBelongsToTeam(jsonlPath, 'missing-team', fileStat)).toBe(false); expect(resolver.teamAffinityHeadMetadataCache.size).toBe(1); expect(resolver.teamAffinityHeadMetadataCache.get(jsonlPath)?.inspectedLineCount).toBe(2); expect(resolver.teamAffinityFileCache.get(`alpha-team\0${jsonlPath}`)).toMatchObject({ belongsToTeam: true, headWindowFull: false, }); expect(resolver.teamAffinityFileCache.get(`beta-team\0${jsonlPath}`)).toMatchObject({ belongsToTeam: true, headWindowFull: false, }); expect(resolver.teamAffinityFileCache.get(`missing-team\0${jsonlPath}`)).toMatchObject({ belongsToTeam: false, headWindowFull: false, }); }); it('refreshes parsed head metadata when the file signature changes before a new team lookup', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/head-cache-refresh')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'changing.jsonl'); await fs.writeFile(jsonlPath, `${teamTextLine('alpha-team')}\n`, 'utf8'); const firstStat = await fs.stat(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'missing-team', firstStat)).toBe(false); expect(resolver.teamAffinityHeadMetadataCache.get(jsonlPath)).toMatchObject({ mtimeMs: firstStat.mtimeMs, size: firstStat.size, inspectedLineCount: 1, }); await fs.writeFile(jsonlPath, `${noiseLine(0)}\n${teamTextLine('beta-team')}\n`, 'utf8'); const updatedAt = new Date(Date.now() + 5_000); await fs.utimes(jsonlPath, updatedAt, updatedAt); const secondStat = await fs.stat(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'beta-team', secondStat)).toBe(true); expect(resolver.teamAffinityHeadMetadataCache.size).toBe(1); expect(resolver.teamAffinityHeadMetadataCache.get(jsonlPath)).toMatchObject({ mtimeMs: secondStat.mtimeMs, size: secondStat.size, inspectedLineCount: 2, }); }); it('caches malformed head lines as inspected non-matches while ignoring blank lines', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/head-cache-malformed')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'malformed.jsonl'); await fs.writeFile(jsonlPath, `\n{not-json\n\n${teamTextLine('malformed-team')}\n`, 'utf8'); const fileStat = await fs.stat(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'malformed-team', fileStat)).toBe(true); const cachedHead = resolver.teamAffinityHeadMetadataCache.get(jsonlPath); expect(cachedHead?.inspectedLineCount).toBe(2); expect(cachedHead?.lines).toHaveLength(2); await fs.unlink(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'missing-team', fileStat)).toBe(false); }); it('keeps cached head metadata bounded to 40 lines when the first lookup matches early', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/head-cache-bound')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'bound.jsonl'); const lines = [ teamTextLine('early-team'), ...Array.from({ length: 39 }, (_, i) => noiseLine(i)), teamTextLine('late-team'), ]; await fs.writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8'); const fileStat = await fs.stat(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'early-team', fileStat)).toBe(true); expect(resolver.teamAffinityHeadMetadataCache.get(jsonlPath)?.inspectedLineCount).toBe(40); expect(resolver.teamAffinityHeadMetadataCache.get(jsonlPath)?.lines).toHaveLength(40); await fs.unlink(jsonlPath); expect(await resolver.fileBelongsToTeam(jsonlPath, 'late-team', fileStat)).toBe(false); expect(resolver.teamAffinityFileCache.get(`late-team\0${jsonlPath}`)).toMatchObject({ belongsToTeam: false, headWindowFull: true, }); }); // The head-window scan reads chunks + splits on '\n' (not readline). These lock the // byte-exact equivalence: CRLF endings, a final line with no trailing newline, a // multi-byte char straddling the 64KB read boundary, and the 40-line window bound. const teamTextLine = (team: string) => JSON.stringify({ type: 'user', message: { role: 'user', content: [{ type: 'text', text: `Team name: ${team}` }] }, }); const noiseLine = (i: number) => JSON.stringify({ type: 'user', message: { role: 'user', content: `noise ${i}` } }); it('matches with CRLF line endings and a final line that has no trailing newline', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'crlf-team'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/crlf')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'c.jsonl'); // CRLF separators; the matching line is last and has NO trailing newline. await fs.writeFile( jsonlPath, `${noiseLine(0)}\r\n${noiseLine(1)}\r\n${teamTextLine(team)}`, 'utf8' ); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); }); it('matches a team mention located past the 64KB read boundary with multi-byte content', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'boundary-team'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/mb')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'mb.jsonl'); // ~40KB of 2-byte Cyrillic per line: the first two lines (~80KB) push the matching // third line past the 64KB read chunk and force a multi-byte char to straddle the // chunk boundary, which the StringDecoder must stitch back together. const big = 'я'.repeat(20_000); const heavy = (i: number) => JSON.stringify({ type: 'user', message: { role: 'user', content: `${big} ${i}` } }); await fs.writeFile(jsonlPath, `${heavy(0)}\n${heavy(1)}\n${teamTextLine(team)}\n`, 'utf8'); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(true); }); it('ignores a team mention that appears only after the 40-line head window', async () => { await setupClaudeRoot(); const resolver = new TeamTranscriptProjectResolver() as unknown as ResolverProbe; const team = 'late-team'; const projectDir = path.join(tmpDir!, 'projects', encodePath('/repo/late')); await fs.mkdir(projectDir, { recursive: true }); const jsonlPath = path.join(projectDir, 'late.jsonl'); // 40 non-matching lines fill the head window; the mention is on line 41. const lines = Array.from({ length: 40 }, (_, i) => noiseLine(i)); lines.push(teamTextLine(team)); await fs.writeFile(jsonlPath, `${lines.join('\n')}\n`, 'utf8'); expect(await resolver.fileBelongsToTeam(jsonlPath, team)).toBe(false); }); });