import * as os from 'os'; import * as path from 'path'; import { afterEach, describe, expect, it } from 'vitest'; import * as fs from 'fs/promises'; import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; import { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; describe('TeamMemberLogsFinder', () => { let tmpDir: string | null = null; afterEach(async () => { setClaudeBasePathOverride(null); if (tmpDir) { await fs.rm(tmpDir, { recursive: true, force: true }); tmpDir = null; } }); 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('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', 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('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 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); }); });