import { TeamMemberLogsFinder } from '@main/services/team/TeamMemberLogsFinder'; import { setClaudeBasePathOverride } from '@main/utils/pathDecoder'; import * as fs from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it, vi } from 'vitest'; describe('TeamMemberLogsFinder', () => { let tmpDir: string | null = null; afterEach(async () => { setClaudeBasePathOverride(null); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; } }); it('builds live log source context without broad transcript discovery', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-live-context-')); setClaudeBasePathOverride(tmpDir); const teamName = 'live-context-team'; const projectPath = '/Users/test/live-context'; const memberProjectPath = '/Users/test/member-cwd'; const runtimeProjectPath = '/Users/test/runtime-bob-cwd'; const projectRoot = path.join(tmpDir, 'projects', '-Users-test-live-context'); const config = { name: teamName, projectPath, leadSessionId: 'lead-session', sessionHistory: ['old-session', 'recent-session'], members: [{ name: 'bob', cwd: memberProjectPath }], }; await fs.mkdir(projectRoot, { recursive: true }); const projectResolver = { getLiveBaseContext: vi.fn(() => Promise.resolve({ projectDir: projectRoot, projectId: '-Users-test-live-context', config, }) ), getContext: vi.fn(() => Promise.reject(new Error('broad context must not be used for live tracking')) ), }; const launchStateStore = { read: vi.fn(() => Promise.resolve({ version: 2, teamName, updatedAt: '2026-05-03T12:00:00.000Z', leadSessionId: 'lead-session', launchPhase: 'active', expectedMembers: ['bob'], members: { bob: { name: 'bob', launchState: 'runtime_pending_bootstrap', agentToolAccepted: true, runtimeAlive: false, bootstrapConfirmed: false, hardFailure: false, runtimeSessionId: 'runtime-bob', cwd: runtimeProjectPath, updatedAt: '2026-05-03T12:00:00.000Z', }, }, summary: {}, teamLaunchState: 'partial_pending', }) ), }; const finder = new TeamMemberLogsFinder( undefined, undefined, undefined, projectResolver as never, launchStateStore as never ); const context = await finder.getLiveLogSourceWatchContext(teamName, { forceRefresh: true }); expect(projectResolver.getLiveBaseContext).toHaveBeenCalledWith( teamName, expect.objectContaining({ forceRefresh: true, extraProjectPathCandidates: [runtimeProjectPath], }) ); expect(projectResolver.getContext).not.toHaveBeenCalled(); expect(context?.projectDir).toBe(projectRoot); expect(context?.watchSessionIds).toEqual([ 'lead-session', 'runtime-bob', 'recent-session', 'old-session', ]); expect(context?.sessionIds).toEqual(context?.watchSessionIds); expect(context?.taskFreshnessRootDirs).toEqual([ path.normalize(projectPath), path.normalize(memberProjectPath), path.normalize(runtimeProjectPath), ]); }); it('dedupes concurrent log source discovery for the same team', async () => { const teamName = 'dedupe-context-team'; let resolveContext!: (value: unknown) => void; const contextPromise = new Promise((resolve) => { resolveContext = resolve; }); const projectResolver = { getContext: vi.fn(() => contextPromise), getLiveBaseContext: vi.fn(), }; const inboxReader = { listInboxNames: vi.fn(async () => []) }; const membersMetaStore = { getMembers: vi.fn(async () => []) }; const finder = new TeamMemberLogsFinder( undefined, inboxReader as never, membersMetaStore as never, projectResolver as never ); const first = finder.getLogSourceWatchContext(teamName); const second = finder.getLogSourceWatchContext(teamName); await Promise.resolve(); expect(projectResolver.getContext).toHaveBeenCalledTimes(1); resolveContext({ projectDir: '/tmp/project', projectId: 'project', sessionIds: ['session-1'], config: { name: teamName, projectPath: '/repo', members: [] }, }); await expect(Promise.all([first, second])).resolves.toEqual([ { projectDir: '/tmp/project', projectPath: '/repo', leadSessionId: undefined, sessionIds: ['session-1'], }, { projectDir: '/tmp/project', projectPath: '/repo', leadSessionId: undefined, sessionIds: ['session-1'], }, ]); await finder.getLogSourceWatchContext(teamName); expect(projectResolver.getContext).toHaveBeenCalledTimes(1); }); it('honors forceRefresh after cached log source discovery', async () => { const teamName = 'force-refresh-context-team'; const contexts = [ { projectDir: '/tmp/project-old', projectId: 'project-old', sessionIds: ['old-session'], config: { name: teamName, projectPath: '/repo-old', members: [] }, }, { projectDir: '/tmp/project-new', projectId: 'project-new', sessionIds: ['new-session'], config: { name: teamName, projectPath: '/repo-new', members: [] }, }, ]; const projectResolver = { getContext: vi.fn(async () => contexts.shift() ?? contexts[0]), getLiveBaseContext: vi.fn(), }; const inboxReader = { listInboxNames: vi.fn(async () => []) }; const membersMetaStore = { getMembers: vi.fn(async () => []) }; const finder = new TeamMemberLogsFinder( undefined, inboxReader as never, membersMetaStore as never, projectResolver as never ); await expect(finder.getLogSourceWatchContext(teamName)).resolves.toMatchObject({ projectDir: '/tmp/project-old', sessionIds: ['old-session'], }); await finder.getLogSourceWatchContext(teamName); expect(projectResolver.getContext).toHaveBeenCalledTimes(1); await expect( finder.getLogSourceWatchContext(teamName, { forceRefresh: true }) ).resolves.toMatchObject({ projectDir: '/tmp/project-new', sessionIds: ['new-session'], }); expect(projectResolver.getContext).toHaveBeenCalledTimes(2); await finder.getLogSourceWatchContext(teamName); expect(projectResolver.getContext).toHaveBeenCalledTimes(2); }); it('dedupes concurrent forceRefresh log source discovery for the same team', async () => { const teamName = 'dedupe-force-refresh-context-team'; let resolveContext!: (value: unknown) => void; const contextPromise = new Promise((resolve) => { resolveContext = resolve; }); const projectResolver = { getContext: vi.fn(() => contextPromise), getLiveBaseContext: vi.fn(), }; const inboxReader = { listInboxNames: vi.fn(async () => []) }; const membersMetaStore = { getMembers: vi.fn(async () => []) }; const finder = new TeamMemberLogsFinder( undefined, inboxReader as never, membersMetaStore as never, projectResolver as never ); const first = finder.getLogSourceWatchContext(teamName, { forceRefresh: true }); const second = finder.getLogSourceWatchContext(teamName, { forceRefresh: true }); await Promise.resolve(); expect(projectResolver.getContext).toHaveBeenCalledTimes(1); resolveContext({ projectDir: '/tmp/project', projectId: 'project', sessionIds: ['session-1'], config: { name: teamName, projectPath: '/repo', members: [] }, }); await expect(Promise.all([first, second])).resolves.toEqual([ { projectDir: '/tmp/project', projectPath: '/repo', leadSessionId: undefined, sessionIds: ['session-1'], }, { projectDir: '/tmp/project', projectPath: '/repo', leadSessionId: undefined, sessionIds: ['session-1'], }, ]); }); it('returns subagent logs for a member and lead session for team-lead', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 't1'; const projectPath = '/Users/test/my-proj'; const projectId = '-Users-test-my-proj'; const leadSessionId = 's1'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), [ JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Lead start' }, }), ].join('\n') + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-abc1234.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "t1" (t1).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const bobLogs = await finder.findMemberLogs(teamName, 'bob'); expect(bobLogs).toHaveLength(1); expect(bobLogs[0]?.kind).toBe('subagent'); if (bobLogs[0]?.kind === 'subagent') { expect(bobLogs[0].subagentId).toBe('abc1234'); expect(bobLogs[0].sessionId).toBe(leadSessionId); expect(bobLogs[0].projectId).toBe(projectId); expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob'); } const leadLogs = await finder.findMemberLogs(teamName, 'team-lead'); expect(leadLogs.some((l) => l.kind === 'lead_session')).toBe(true); const lead = leadLogs.find((l) => l.kind === 'lead_session'); expect(lead?.sessionId).toBe(leadSessionId); expect(lead?.projectId).toBe(projectId); }); it('returns root member sessions when config.projectPath is missing but member cwd is present', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'signal-ops-root'; const projectPath = '/Users/test/signal-ops-root'; const projectId = '-Users-test-signal-ops-root'; const leadSessionId = 'lead-root'; const memberSessionId = 'member-bob-root'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, { name: 'bob', agentType: 'general-purpose', cwd: projectPath }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(projectRoot, { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-04-15T14:02:00.000Z', type: 'user', teamName, agentName: 'team-lead', message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, }) + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, `${memberSessionId}.jsonl`), [ JSON.stringify({ timestamp: '2026-04-15T14:02:01.000Z', type: 'user', teamName, agentName: 'bob', message: { role: 'user', content: `You are bootstrapping into team "${teamName}" as member "bob".`, }, }), JSON.stringify({ timestamp: '2026-04-15T14:02:05.000Z', type: 'assistant', teamName, agentName: 'bob', message: { role: 'assistant', content: [ { type: 'tool_use', id: 'call-task-start', name: 'mcp__agent-teams__task_start', input: { teamName, taskId: 'task-root-1', }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const bobLogs = await finder.findMemberLogs(teamName, 'bob'); const taskLogs = await finder.findLogsForTask(teamName, 'task-root-1'); const attributedFiles = await finder.listAttributedMemberFiles(teamName); expect(bobLogs).toHaveLength(1); expect(bobLogs[0]?.kind).toBe('member_session'); if (bobLogs[0]?.kind === 'member_session') { expect(bobLogs[0].sessionId).toBe(memberSessionId); expect(bobLogs[0].projectId).toBe(projectId); expect(bobLogs[0].memberName?.toLowerCase()).toBe('bob'); expect(bobLogs[0].filePath).toBe(path.join(projectRoot, `${memberSessionId}.jsonl`)); } expect( taskLogs.some( (log) => log.kind === 'member_session' && log.sessionId === memberSessionId && log.memberName?.toLowerCase() === 'bob' ) ).toBe(true); expect(attributedFiles).toEqual([ { memberName: 'bob', sessionId: memberSessionId, filePath: path.join(projectRoot, `${memberSessionId}.jsonl`), mtimeMs: expect.any(Number), }, ]); }); it('returns recent attributed member log file refs in one batch for advisory scans', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'runtime-advisory-batch'; const projectPath = '/Users/test/runtime-advisory-batch'; const projectId = '-Users-test-runtime-advisory-batch'; const leadSessionId = 'lead-session'; const bobSessionId = 'member-bob-session'; const now = new Date(); const old = new Date(Date.now() - 30 * 60_000); await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'Alice', agentType: 'general-purpose' }, { name: 'Bob', agentType: 'general-purpose' }, { name: 'Tom', agentType: 'general-purpose' }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); await fs.writeFile( leadPath, [ JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, }), JSON.stringify({ timestamp: now.toISOString(), type: 'system', subtype: 'api_error', retryInMs: 45_000, }), ].join('\n') + '\n', 'utf8' ); const alicePath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice.jsonl'); await fs.writeFile( alicePath, [ JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `You are Alice, a reviewer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: now.toISOString(), type: 'system', subtype: 'api_error', retryInMs: 45_000, }), ].join('\n') + '\n', 'utf8' ); const bobPath = path.join(projectRoot, `${bobSessionId}.jsonl`); await fs.writeFile( bobPath, [ JSON.stringify({ timestamp: now.toISOString(), type: 'user', teamName, agentName: 'Bob', message: { role: 'user', content: `You are bootstrapping into team "${teamName}" as member "Bob".`, }, }), JSON.stringify({ timestamp: now.toISOString(), type: 'system', subtype: 'api_error', retryInMs: 45_000, }), ].join('\n') + '\n', 'utf8' ); const tomOldPath = path.join(projectRoot, leadSessionId, 'subagents', 'agent-tom.jsonl'); await fs.writeFile( tomOldPath, JSON.stringify({ timestamp: old.toISOString(), type: 'user', message: { role: 'user', content: `You are Tom, a developer on team "${teamName}" (${teamName}).`, }, }) + '\n', 'utf8' ); await fs.utimes(tomOldPath, old, old); const finder = new TeamMemberLogsFinder(); const refs = await finder.findRecentMemberLogFileRefsByMember( teamName, ['team-lead', 'Alice', 'Bob', 'Tom'], Date.now() - 10 * 60_000 ); expect(refs).toEqual( expect.arrayContaining([ expect.objectContaining({ memberName: 'team-lead', filePath: leadPath }), expect.objectContaining({ memberName: 'Alice', filePath: alicePath }), expect.objectContaining({ memberName: 'Bob', filePath: bobPath }), ]) ); expect(refs.some((ref) => ref.memberName === 'Tom')).toBe(false); }); it('does not leak old same-workspace subagent logs into a newly created team', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-scope-')); setClaudeBasePathOverride(tmpDir); const teamName = 'aurora-room-scope'; const projectPath = '/Users/test/shared-workspace'; const projectId = '-Users-test-shared-workspace'; const leadSessionId = 'fresh-lead-session'; const unrelatedSessionId = 'old-other-team-session'; const now = new Date(); await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'Alice', agentType: 'general-purpose' }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); const currentSubagentsDir = path.join(projectRoot, leadSessionId, 'subagents'); const unrelatedSubagentsDir = path.join(projectRoot, unrelatedSessionId, 'subagents'); await fs.mkdir(currentSubagentsDir, { recursive: true }); await fs.mkdir(unrelatedSubagentsDir, { recursive: true }); const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); await fs.writeFile( leadPath, JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, }) + '\n', 'utf8' ); const currentAlicePath = path.join(currentSubagentsDir, 'agent-alice.jsonl'); await fs.writeFile( currentAlicePath, JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `You are Alice, a developer on team "${teamName}" (${teamName}).`, }, }) + '\n', 'utf8' ); const unrelatedAlicePath = path.join(unrelatedSubagentsDir, 'agent-alice.jsonl'); await fs.writeFile( unrelatedAlicePath, JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: 'You are Alice, a developer on team "old-team" (old-team).', }, }) + '\n', 'utf8' ); const refs = await new TeamMemberLogsFinder().findRecentMemberLogFileRefsByMember( teamName, ['team-lead', 'Alice'], { forceRefresh: true } ); expect(refs).toEqual( expect.arrayContaining([ expect.objectContaining({ memberName: 'team-lead', filePath: leadPath }), expect.objectContaining({ memberName: 'Alice', filePath: currentAlicePath }), ]) ); expect(refs.some((ref) => ref.filePath === unrelatedAlicePath)).toBe(false); }); it('can skip untracked team subagent session discovery for graph previews', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-preview-scope-')); setClaudeBasePathOverride(tmpDir); const teamName = 'preview-known-session-scope'; const projectPath = '/Users/test/preview-known-session-scope'; const projectId = '-Users-test-preview-known-session-scope'; const leadSessionId = 'known-lead-session'; const untrackedSessionId = 'team-subagent-only-session'; const now = new Date(); await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'Alice', agentType: 'general-purpose' }, { name: 'Bob', agentType: 'general-purpose' }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); const knownSubagentsDir = path.join(projectRoot, leadSessionId, 'subagents'); const untrackedSubagentsDir = path.join(projectRoot, untrackedSessionId, 'subagents'); await fs.mkdir(knownSubagentsDir, { recursive: true }); await fs.mkdir(untrackedSubagentsDir, { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, }) + '\n', 'utf8' ); const knownAlicePath = path.join(knownSubagentsDir, 'agent-alice.jsonl'); await fs.writeFile( knownAlicePath, JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `You are Alice, a developer on team "${teamName}" (${teamName}).`, }, }) + '\n', 'utf8' ); const untrackedBobPath = path.join(untrackedSubagentsDir, 'agent-bob.jsonl'); await fs.writeFile( untrackedBobPath, JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `You are Bob, a developer on team "${teamName}" (${teamName}).`, }, }) + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const fastRefs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['Alice', 'Bob'], { forceRefresh: true, includeTeamSubagentSessionDiscovery: false, }); expect(fastRefs.some((ref) => ref.filePath === knownAlicePath)).toBe(true); expect(fastRefs.some((ref) => ref.filePath === untrackedBobPath)).toBe(false); const fullRefs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['Alice', 'Bob'], { forceRefresh: true, }); expect(fullRefs.some((ref) => ref.filePath === knownAlicePath)).toBe(true); expect(fullRefs.some((ref) => ref.filePath === untrackedBobPath)).toBe(true); }); it('applies recent-ref object options to discovery, lead refs, metadata, and requested-member attribution', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'member-stream-ref-options'; const projectPath = '/Users/test/member-stream-ref-options'; const projectId = '-Users-test-member-stream-ref-options'; const leadSessionId = 'lead-session'; const recentSince = Date.now() - 10 * 60_000; const old = new Date(Date.now() - 30 * 60_000); const now = new Date(); const projectRoot = path.join(tmpDir, 'projects', projectId); const subagentsDir = path.join(projectRoot, leadSessionId, 'subagents'); await fs.mkdir(subagentsDir, { recursive: true }); const leadPath = path.join(projectRoot, `${leadSessionId}.jsonl`); await fs.writeFile( leadPath, JSON.stringify({ timestamp: old.toISOString(), type: 'user', message: { role: 'user', content: `Lead for team "${teamName}" (${teamName})` }, }) + '\n', 'utf8' ); await fs.utimes(leadPath, old, old); const zoePath = path.join(subagentsDir, 'agent-zoe.jsonl'); await fs.writeFile( zoePath, [ JSON.stringify({ timestamp: now.toISOString(), type: 'user', message: { role: 'user', content: `You are Zoe, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: now.toISOString(), type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Ready' }] }, }), ].join('\n') + '\n', 'utf8' ); await fs.utimes(zoePath, now, now); const projectResolver = { getContext: vi.fn(() => Promise.resolve({ projectDir: projectRoot, projectId, sessionIds: [leadSessionId], config: { name: teamName, projectPath, leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead' }], }, }) ), }; const finder = new TeamMemberLogsFinder( undefined, undefined, undefined, projectResolver as never ); const refs = await finder.findRecentMemberLogFileRefsByMember(teamName, ['team-lead', 'Zoe'], { mtimeSinceMs: recentSince, forceRefresh: true, }); expect(projectResolver.getContext).toHaveBeenCalledWith( teamName, expect.objectContaining({ forceRefresh: true }) ); expect(refs).toEqual([ expect.objectContaining({ memberName: 'Zoe', filePath: zoePath, kind: 'subagent', sizeBytes: expect.any(Number), }), ]); expect(refs.some((ref) => ref.filePath === leadPath)).toBe(false); }); it('listAttributedSubagentFiles only returns files from the current lead session for live tracking', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'live-tools'; const projectPath = '/Users/test/live-tools'; const projectId = '-Users-test-live-tools'; const currentSessionId = 'session-current'; const oldSessionId = 'session-old'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify( { name: teamName, projectPath, leadSessionId: currentSessionId, sessionHistory: [oldSessionId], members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }, null, 2 ), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, currentSessionId, 'subagents'), { recursive: true }); await fs.mkdir(path.join(projectRoot, oldSessionId, 'subagents'), { recursive: true }); const attributedLog = [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, }), ].join('\n') + '\n'; await fs.writeFile( path.join(projectRoot, currentSessionId, 'subagents', 'agent-current.jsonl'), attributedLog, 'utf8' ); await fs.writeFile( path.join(projectRoot, oldSessionId, 'subagents', 'agent-old.jsonl'), attributedLog, 'utf8' ); const finder = new TeamMemberLogsFinder(); const files = await finder.listAttributedSubagentFiles(teamName); expect(files).toHaveLength(1); expect(files[0]?.sessionId).toBe(currentSessionId); expect(files[0]?.filePath).toContain(path.join(currentSessionId, 'subagents')); }); it('detects member via teammate_id attribute in tag', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 't2'; const projectPath = '/Users/test/proj2'; const projectId = '-Users-test-proj2'; const leadSessionId = 's2'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Lead session file await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, }) + '\n', 'utf8' ); // Subagent file using format (no "You are" pattern) await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-xyz789.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', protocolKind: 'teammate-message', message: { role: 'user', content: 'Please implement the login page', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:05.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Working on it' }] }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const aliceLogs = await finder.findMemberLogs(teamName, 'alice'); expect(aliceLogs).toHaveLength(1); expect(aliceLogs[0]?.kind).toBe('subagent'); if (aliceLogs[0]?.kind === 'subagent') { expect(aliceLogs[0].subagentId).toBe('xyz789'); expect(aliceLogs[0].description).toBe('Implement feature X'); } }); it('ignores synthetic replay text when deriving subagent attribution description', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'tSyntheticReplay'; const projectPath = '/Users/test/projSyntheticReplay'; const projectId = '-Users-test-projSyntheticReplay'; const leadSessionId = 'sSyntheticReplay'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'alice', agentType: 'general-purpose' }, { name: 'bob', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, }) + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob001.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', isReplay: true, isSynthetic: true, message: { role: 'user', content: 'Human: I tested the feature looks good for alice', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'user', message: { role: 'user', content: 'Please implement it', }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const bobLogs = await finder.findMemberLogs(teamName, 'bob'); const aliceLogs = await finder.findMemberLogs(teamName, 'alice'); expect(aliceLogs).toHaveLength(0); expect(bobLogs).toHaveLength(1); expect(bobLogs[0]?.description).toBe('Build the real task'); }); it('ignores raw tool_result content when deriving subagent attribution description', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'tool-result-raw-team'; const projectPath = '/Users/test/tool-result-raw'; const projectId = '-Users-test-tool-result-raw'; const leadSessionId = 'lead-tool-result-raw'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [{ name: 'bob', agentType: 'general-purpose' }], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, }) + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob001.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: [ { type: 'tool_result', tool_use_id: 'tool-1', content: 'result text' }, { type: 'text', text: 'Tool result should not become the description' }, ], }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'user', message: { role: 'user', content: 'Please implement it', }, }), ].join('\n') + '\n', 'utf8' ); const bobLogs = await new TeamMemberLogsFinder().findMemberLogs(teamName, 'bob'); expect(bobLogs).toHaveLength(1); expect(bobLogs[0]?.description).toBe('Build the real task'); }); it('routing.sender overrides teammate_id="team-lead" from spawn message', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'tA'; const projectPath = '/Users/test/projA'; const projectId = '-Users-test-projA'; const leadSessionId = 'sA'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'carol', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Lead session file await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, }) + '\n', 'utf8' ); // Subagent file: first message has teammate_id="team-lead" (sender), // but routing.sender="carol" (the actual agent) appears later. await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-carol01.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are carol, a developer.', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Working on it' }] }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:03.000Z', toolUseResult: { routing: { sender: 'carol' } }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const carolLogs = await finder.findMemberLogs(teamName, 'carol'); expect(carolLogs).toHaveLength(1); expect(carolLogs[0]?.kind).toBe('subagent'); if (carolLogs[0]?.kind === 'subagent') { expect(carolLogs[0].subagentId).toBe('carol01'); expect(carolLogs[0].description).toBe('Fix button layout'); } }); it('process.team.memberName overrides teammate_id and text_mention', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'tB'; const projectPath = '/Users/test/projB'; const projectId = '-Users-test-projB'; const leadSessionId = 'sB'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, { name: 'bob', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Lead session file await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Start' }, }) + '\n', 'utf8' ); // Subagent file: teammate_id="alice" but process.team.memberName="bob" await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob01.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'Do the work', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', process: { team: { memberName: 'bob' } }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const bobLogs = await finder.findMemberLogs(teamName, 'bob'); expect(bobLogs).toHaveLength(1); expect(bobLogs[0]?.kind).toBe('subagent'); if (bobLogs[0]?.kind === 'subagent') { expect(bobLogs[0].subagentId).toBe('bob01'); } // Verify alice does NOT get this file const aliceLogs = await finder.findMemberLogs(teamName, 'alice'); const aliceSubagents = aliceLogs.filter((l) => l.kind === 'subagent'); expect(aliceSubagents).toHaveLength(0); }); it('reports accurate messageCount from full file (not limited by scan lines)', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 't3'; const projectPath = '/Users/test/proj3'; const projectId = '-Users-test-proj3'; const leadSessionId = 's3'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'carol', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'Go' }, }) + '\n', 'utf8' ); // Build a 200-line subagent file — well beyond ATTRIBUTION_SCAN_LINES (50) const lines: string[] = []; // First line: spawn prompt with teammate_id lines.push( JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'Do 200 things', }, }) ); // Lines 2-200: alternating assistant/user messages for (let i = 2; i <= 200; i++) { const role = i % 2 === 0 ? 'assistant' : 'user'; lines.push( JSON.stringify({ timestamp: `2026-01-01T00:${String(Math.floor(i / 60)).padStart(2, '0')}:${String(i % 60).padStart(2, '0')}.000Z`, type: role, message: { role, content: `Message ${i}` }, }) ); } await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-big123.jsonl'), lines.join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const carolLogs = await finder.findMemberLogs(teamName, 'carol'); expect(carolLogs).toHaveLength(1); expect(carolLogs[0]?.kind).toBe('subagent'); // Full file has 200 messages — must NOT be capped at 50 or 100 expect(carolLogs[0]?.messageCount).toBe(200); }); it('findLogsForTask does not treat arbitrary "#" as a task reference', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-logs-')); setClaudeBasePathOverride(tmpDir); const teamName = 't4'; const projectPath = '/Users/test/proj4'; const projectId = '-Users-test-proj4'; const leadSessionId = 's4'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Lead session mentions "PR #1" but NOT a task reference await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), [ JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Fix PR #1 please' }] }, }), ].join('\n') + '\n', 'utf8' ); // Subagent session includes a structured taskId reference (should match) await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-abc111.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "t4" (t4).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { team_name: teamName, taskId: '1', status: 'in_progress' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const logs = await finder.findLogsForTask(teamName, '1'); // Should include the subagent log, but must NOT include the lead session just because it had "PR #1" expect(logs.some((l) => l.kind === 'lead_session')).toBe(false); expect(logs.some((l) => l.kind === 'subagent')).toBe(true); }); it('findLogsForTask includes only owner sessions overlapping workIntervals', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-since-')); setClaudeBasePathOverride(tmpDir); const teamName = 't5'; const projectPath = '/Users/test/proj5'; const projectId = '-Users-test-proj5'; const leadSessionId = 's5'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Alice file references taskId 10 via structured tool input (so results is non-empty). await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are alice, a developer on team "t5" (t5).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { team_name: teamName, taskId: '10', status: 'pending' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); // Bob has an old session (should NOT be pulled in by owner include). await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-old.jsonl'), [ JSON.stringify({ timestamp: '2025-12-31T00:00:00.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' }, }), JSON.stringify({ timestamp: '2025-12-31T00:00:01.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Old work' }] }, }), ].join('\n') + '\n', 'utf8' ); // Bob has a recent session within workIntervals (should be included). await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-new.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T12:00:00.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "t5" (t5).' }, }), JSON.stringify({ timestamp: '2026-01-01T12:00:01.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'New work' }] }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const logs = await finder.findLogsForTask(teamName, '10', { owner: 'bob', status: 'in_progress', intervals: [ { startedAt: '2026-01-01T10:00:00.000Z', completedAt: '2026-01-01T13:00:00.000Z' }, ], }); const bobDescriptions = logs .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') .map((l) => l.description); expect(bobDescriptions.some((d) => d.includes('Old'))).toBe(false); // At least one bob log should be present (the recent one). expect(logs.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob')).toBe( true ); }); it('findLogsForTask does not treat malformed empty completedAt intervals as open', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-owner-malformed-')); setClaudeBasePathOverride(tmpDir); const teamName = 't5-malformed'; const projectPath = '/Users/test/proj5-malformed'; const projectId = '-Users-test-proj5-malformed'; const leadSessionId = 's5-malformed'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-alice10.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: `You are alice, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { team_name: teamName, taskId: '10', status: 'pending' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-near.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T10:00:01.000Z', type: 'user', message: { role: 'user', content: `You are bob, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: '2026-01-01T10:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Near malformed interval' }], }, }), ].join('\n') + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob-late.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T12:00:00.000Z', type: 'user', message: { role: 'user', content: `You are bob, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: '2026-01-01T12:00:01.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Late malformed interval' }], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const options = { owner: 'bob', status: 'in_progress', intervals: [{ startedAt: '2026-01-01T10:00:00.000Z', completedAt: '' }], }; const logs = await finder.findLogsForTask(teamName, '10', options); const bobFilePaths = logs .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') .map((l) => l.filePath ?? ''); expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe(true); expect(bobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false); const reversedIntervalLogs = await finder.findLogsForTask(teamName, '10', { ...options, intervals: [ { startedAt: '2026-01-01T10:00:00.000Z', completedAt: '2026-01-01T09:59:00.000Z', }, ], }); const reversedBobFilePaths = reversedIntervalLogs .filter((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') .map((l) => l.filePath ?? ''); expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-near.jsonl'))).toBe( true ); expect(reversedBobFilePaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe( false ); const refs = await finder.findLogFileRefsForTask(teamName, '10', options); const bobRefPaths = refs .filter((ref) => ref.memberName.toLowerCase() === 'bob') .map((ref) => ref.filePath); expect(bobRefPaths.some((filePath) => filePath.endsWith('agent-bob-late.jsonl'))).toBe(false); }); it('findLogsForTask does not auto-include owner sessions when owner is team-lead', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-lead-owner-')); setClaudeBasePathOverride(tmpDir); const teamName = 't6'; const projectPath = '/Users/test/proj6'; const projectId = '-Users-test-proj6'; const leadSessionId = 's6'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, leadSessionId, members: [{ name: 'team-lead', agentType: 'team-lead' }], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); // Lead session exists but does NOT reference taskId 42. await fs.writeFile( path.join(projectRoot, `${leadSessionId}.jsonl`), JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'Hello' }] }, }) + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const logs = await finder.findLogsForTask(teamName, '42', { owner: 'team-lead', status: 'in_progress', intervals: [{ startedAt: '2026-01-01T10:00:00.000Z' }], }); // We only want sessions that explicitly reference the task id. expect(logs).toHaveLength(0); }); it('findLogsForTask does not mix tasks across teams sharing a projectPath', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-task-cross-team-')); setClaudeBasePathOverride(tmpDir); const projectPath = '/Users/test/shared-proj'; const projectId = '-Users-test-shared-proj'; const sessionId = 's-shared'; // Two teams pointing at the same project path (realistic when multiple teams work in one repo) const teamA = 'team-a'; const teamB = 'team-b'; await fs.mkdir(path.join(tmpDir, 'teams', teamA), { recursive: true }); await fs.mkdir(path.join(tmpDir, 'teams', teamB), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamA, 'config.json'), JSON.stringify({ name: teamA, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); await fs.writeFile( path.join(tmpDir, 'teams', teamB, 'config.json'), JSON.stringify({ name: teamB, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); // Team A subagent referencing taskId 9 (no team_name in tool input, as in Solo/older runs) await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-a1.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are alice, a developer on team "team-a" (team-a).', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '9', status: 'in_progress' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); // Team B subagent referencing taskId 9 (must NOT be included when querying team-a) await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-b1.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:03.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "team-b" (team-b).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:04.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '9', status: 'in_progress' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const logsForA = await finder.findLogsForTask(teamA, '9'); expect( logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'alice') ).toBe(true); expect( logsForA.some((l) => l.kind === 'subagent' && l.memberName?.toLowerCase() === 'bob') ).toBe(false); }); it('detects structured task markers and ignores legacy teamctl command lines', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-marker-logs-')); const structuredPath = path.join(tmpDir, 'structured.jsonl'); await fs.writeFile( structuredPath, JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'task_start', input: { teamName: 'demo', taskId: 'task-42' }, }, ], }, }) + '\n', 'utf8' ); const legacyPath = path.join(tmpDir, 'legacy.jsonl'); await fs.writeFile( legacyPath, JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'Bash', input: { command: 'node "teamctl.js" --team demo task start task-42' }, }, ], }, }) + '\n', 'utf8' ); const noisePath = path.join(tmpDir, 'noise.jsonl'); await fs.writeFile( noisePath, JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'No task markers here' }] }, }) + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); await expect(finder.hasTaskUpdateMarker(structuredPath, 'task-42')).resolves.toBe(true); await expect(finder.hasTaskUpdateMarker(legacyPath, 'task-42')).resolves.toBe(false); await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); }); it('detects fully-qualified agent-teams task markers in JSONL', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-markers-')); const qualifiedPath = path.join(tmpDir, 'qualified.jsonl'); await fs.writeFile( qualifiedPath, JSON.stringify({ timestamp: '2026-01-01T00:00:00.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'mcp__agent-teams__task_start', input: { teamName: 'demo', taskId: 'task-42' }, }, ], }, }) + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); await expect(finder.hasTaskUpdateMarker(qualifiedPath, 'task-42')).resolves.toBe(true); }); it('findLogFileRefsForTask returns correct refs for a task', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-')); setClaudeBasePathOverride(tmpDir); const teamName = 'refs-team'; const projectPath = '/Users/test/ref-proj'; const projectId = '-Users-test-ref-proj'; const sessionId = 'sr1'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'dev', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-ref1.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are dev, a developer on team "refs-team" (refs-team).', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '5', status: 'in_progress' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const refs = await finder.findLogFileRefsForTask(teamName, '5'); expect(refs).toHaveLength(1); expect(refs[0].memberName.toLowerCase()).toBe('dev'); expect(refs[0].filePath).toContain('agent-ref1.jsonl'); }); it('indexes task mentions without changing matching semantics', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-index-')); setClaudeBasePathOverride(tmpDir); const teamName = 'mention-index-team'; const projectPath = '/Users/test/mention-index-proj'; const projectId = '-Users-test-mention-index-proj'; const sessionId = 'six'; const fullTaskId = 'abcdef12-1111-4222-8333-444444444444'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'dev', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-indexed.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: `You are dev, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskGet', input: { taskId: 'ignored-task' } }, { type: 'tool_use', name: 'TaskUpdate', input: { taskId: fullTaskId.slice(0, 8), status: 'completed' }, }, { type: 'tool_use', name: 'TaskUpdate', input: { taskId: 'wrong-team-task', teamName: 'other-team', status: 'completed' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-no-team.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:03.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: 'no-team-task', status: 'completed' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); await expect(finder.findLogFileRefsForTask(teamName, fullTaskId)).resolves.toHaveLength(1); await expect(finder.findLogFileRefsForTask(teamName, 'ignored-task')).resolves.toHaveLength(0); await expect(finder.findLogFileRefsForTask(teamName, 'wrong-team-task')).resolves.toHaveLength( 0 ); await expect(finder.findLogFileRefsForTask(teamName, 'no-team-task')).resolves.toHaveLength(0); }); it('findLogFileRefsForTask does not mix tasks across teams sharing a projectPath', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-cross-')); setClaudeBasePathOverride(tmpDir); const projectPath = '/Users/test/shared-ref-proj'; const projectId = '-Users-test-shared-ref-proj'; const sessionId = 'sref'; const teamA = 'ref-a'; const teamB = 'ref-b'; await fs.mkdir(path.join(tmpDir, 'teams', teamA), { recursive: true }); await fs.mkdir(path.join(tmpDir, 'teams', teamB), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamA, 'config.json'), JSON.stringify({ name: teamA, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'alice', agentType: 'general-purpose' }, ], }), 'utf8' ); await fs.writeFile( path.join(tmpDir, 'teams', teamB, 'config.json'), JSON.stringify({ name: teamB, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'bob', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); // Team A agent with task 7 await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-ra.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are alice, a developer on team "ref-a" (ref-a).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '7', status: 'completed' } }, ], }, }), ].join('\n') + '\n', 'utf8' ); // Team B agent with same task id 7 await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-rb.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:03.000Z', type: 'user', message: { role: 'user', content: 'You are bob, a developer on team "ref-b" (ref-b).' }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:04.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '7', status: 'completed' } }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); const refsA = await finder.findLogFileRefsForTask(teamA, '7'); expect(refsA.some((r) => r.memberName.toLowerCase() === 'alice')).toBe(true); expect(refsA.some((r) => r.memberName.toLowerCase() === 'bob')).toBe(false); }); it('findLogFileRefsForTask does not duplicate refs for owner logs', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-dedup-')); setClaudeBasePathOverride(tmpDir); const teamName = 'dedup-team'; const projectPath = '/Users/test/dedup-proj'; const projectId = '-Users-test-dedup-proj'; const sessionId = 'sdd'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'dev', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); // Agent file that mentions task AND belongs to owner 'dev' await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', 'agent-dd1.jsonl'), [ JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', message: { role: 'user', content: 'You are dev, a developer on team "dedup-team" (dedup-team).', }, }), JSON.stringify({ timestamp: '2026-01-01T00:00:02.000Z', type: 'assistant', message: { role: 'assistant', content: [ { type: 'tool_use', name: 'TaskUpdate', input: { taskId: '3', status: 'in_progress' }, }, ], }, }), ].join('\n') + '\n', 'utf8' ); const finder = new TeamMemberLogsFinder(); // File found as direct task hit AND as owner log — should appear once const refs = await finder.findLogFileRefsForTask(teamName, '3', { owner: 'dev', status: 'in_progress', }); // Count refs with this file path — should be exactly 1 const deduped = refs.filter((r) => r.filePath.includes('agent-dd1.jsonl')); expect(deduped).toHaveLength(1); expect(deduped[0].memberName.toLowerCase()).toBe('dev'); }); it('findMemberLogs returns results sorted by startTime descending', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-sort-')); setClaudeBasePathOverride(tmpDir); const teamName = 'sort-team'; const projectPath = '/Users/test/sort-proj'; const projectId = '-Users-test-sort-proj'; const sessionId = 'ss1'; await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); await fs.writeFile( path.join(tmpDir, 'teams', teamName, 'config.json'), JSON.stringify({ name: teamName, projectPath, members: [ { name: 'team-lead', agentType: 'team-lead' }, { name: 'dev', agentType: 'general-purpose' }, ], }), 'utf8' ); const projectRoot = path.join(tmpDir, 'projects', projectId); await fs.mkdir(path.join(projectRoot, sessionId, 'subagents'), { recursive: true }); // 3 agent files with different startTimes: 00:03, 00:01, 00:02 for (const [id, ts] of [ ['agent-s1.jsonl', '2026-01-01T00:00:03.000Z'], ['agent-s2.jsonl', '2026-01-01T00:00:01.000Z'], ['agent-s3.jsonl', '2026-01-01T00:00:02.000Z'], ] as const) { await fs.writeFile( path.join(projectRoot, sessionId, 'subagents', id), [ JSON.stringify({ timestamp: ts, type: 'user', message: { role: 'user', content: `You are dev, a developer on team "${teamName}" (${teamName}).`, }, }), JSON.stringify({ timestamp: ts, type: 'assistant', message: { role: 'assistant', content: [{ type: 'text', text: 'OK' }] }, }), ].join('\n') + '\n', 'utf8' ); } const finder = new TeamMemberLogsFinder(); const logs = await finder.findMemberLogs(teamName, 'dev'); expect(logs).toHaveLength(3); // Must be descending: 00:03, 00:02, 00:01 const times = logs.map((l) => new Date(l.startTime).getTime()); expect(times[0]).toBeGreaterThan(times[1]); expect(times[1]).toBeGreaterThan(times[2]); }); });