# Conflicts: # src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx # test/main/services/team/TeamMemberLogsFinder.test.ts
1617 lines
52 KiB
TypeScript
1617 lines
52 KiB
TypeScript
import * as os from 'os';
|
|
import * as path from 'path';
|
|
import { afterEach, describe, expect, it, vi } 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('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('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('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 <teammate-message> 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 <teammate-message> 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:
|
|
'<teammate-message teammate_id="alice" color="green" summary="Implement feature X">Please implement the login page</teammate-message>',
|
|
},
|
|
}),
|
|
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('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:
|
|
'<teammate-message teammate_id="team-lead" color="yellow" summary="Fix button layout">You are carol, a developer.</teammate-message>',
|
|
},
|
|
}),
|
|
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:
|
|
'<teammate-message teammate_id="alice" color="green" summary="Refactor code">Do the work</teammate-message>',
|
|
},
|
|
}),
|
|
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:
|
|
'<teammate-message teammate_id="carol" color="yellow" summary="Big task">Do 200 things</teammate-message>',
|
|
},
|
|
})
|
|
);
|
|
// 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 "#<id>" 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);
|
|
});
|
|
|
|
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('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]);
|
|
});
|
|
});
|