agent-ecosystem/test/main/services/team/TeamMemberLogsFinder.test.ts
Илия 3849c01955
fix(provenance): classify synthetic user turns
* fix(provenance): classify synthetic user turns

* fix(provenance): keep assistant display rendering intact

* fix(provenance): preserve source tool result rows
2026-05-26 23:51:17 +03:00

2316 lines
75 KiB
TypeScript

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 <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',
protocolKind: 'teammate-message',
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('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:
'<teammate-message teammate_id="bob" color="blue" summary="Build the real task">Please implement it</teammate-message>',
},
}),
].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:
'<teammate-message teammate_id="bob" color="blue" summary="Build the real task">Please implement it</teammate-message>',
},
}),
].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:
'<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 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]);
});
});