refactor: enhance member detection signal handling in TeamMemberLogsFinder

- Introduced a new system for collecting and prioritizing detection signals for team members, improving accuracy in identifying the correct member from logs.
- Added a `DetectionSignal` interface and a `SIGNAL_PRECEDENCE` array to define the order of reliability for different signal sources.
- Refactored the `attributeSubagent` method to collect signals instead of immediately determining the detected member, allowing for better decision-making based on signal precedence.
- Implemented a new static method `selectBestSignal` to determine the most reliable member signal based on the defined precedence.
- Expanded unit tests to validate the new signal handling logic and ensure correct member identification under various scenarios.
This commit is contained in:
iliya 2026-03-14 14:34:56 +02:00
parent e92d4658f4
commit 0e84650602
2 changed files with 214 additions and 34 deletions

View file

@ -25,6 +25,28 @@ const ATTRIBUTION_SCAN_LINES = 50;
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
const FILE_MENTIONS_CACHE_MAX = 200;
/** Signal sources for subagent member attribution, ordered by reliability. */
type AttributionSignalSource = 'process_team' | 'routing_sender' | 'teammate_id' | 'text_mention';
interface DetectionSignal {
member: string;
source: AttributionSignalSource;
}
/**
* Precedence order for attribution signals (most reliable first).
* - process_team: from system init message written by CLI, definitive
* - routing_sender: from toolUseResult.routing identifies the actual agent
* - teammate_id: from <teammate-message> XML identifies the message SENDER, not the agent
* - text_mention: regex match of member name in text lowest reliability
*/
const SIGNAL_PRECEDENCE: readonly AttributionSignalSource[] = [
'process_team',
'routing_sender',
'teammate_id',
'text_mention',
];
interface StreamedMetadata {
firstTimestamp: string | null;
lastTimestamp: string | null;
@ -936,6 +958,9 @@ export class TeamMemberLogsFinder {
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
* and extract a human-readable description from the first user message.
* Returns null if the file is a warmup session or empty.
*
* Collects ALL detection signals, then selects the best one by precedence
* (process_team > routing_sender > teammate_id > text_mention).
*/
private async attributeSubagent(
filePath: string,
@ -969,16 +994,13 @@ export class TeamMemberLogsFinder {
if (lines.length === 0) return null;
let description = '';
let detectedMember: string | null = null;
let detectionPriority = 0;
const signals: DetectionSignal[] = [];
let firstTimestamp: string | null = null;
for (const line of lines) {
if (!firstTimestamp) {
firstTimestamp = this.extractTimestampFromLine(line);
}
// Early exit: both objectives met (member detected at max priority + description found)
if (detectionPriority >= 3 && description) break;
try {
const msg = JSON.parse(line) as Record<string, unknown>;
@ -991,7 +1013,7 @@ export class TeamMemberLogsFinder {
return null;
}
// Extract description from first user message + teammate_id attribution
// Extract description from first user message + collect teammate_id signal
if (role === 'user' && textContent) {
if (textContent.trimStart().startsWith('<teammate-message')) {
const parsed = parseAllTeammateMessages(textContent);
@ -1001,12 +1023,11 @@ export class TeamMemberLogsFinder {
}
// teammate_id identifies the MESSAGE SENDER (e.g. "team-lead"), not the agent
// owning this file. Use priority 2 so routing.sender (priority 3) can override.
if (detectionPriority < 2 && parsed[0]?.teammateId) {
// owning this file. Collected as a signal — higher-precedence sources override.
if (parsed[0]?.teammateId) {
const tmId = parsed[0].teammateId.trim().toLowerCase();
if (tmId.length > 0 && knownMembers.has(tmId)) {
detectedMember = parsed[0].teammateId.trim();
detectionPriority = 2;
signals.push({ member: parsed[0].teammateId.trim(), source: 'teammate_id' });
}
}
} else if (!description) {
@ -1014,41 +1035,33 @@ export class TeamMemberLogsFinder {
}
}
// --- Multi-signal member detection ---
// Higher priority signals override lower priority ones (skip if already at max)
if (detectionPriority < 3) {
const detection = this.detectMemberFromMessage(msg, knownMembers);
if (detection && detection.priority > detectionPriority) {
detectedMember = detection.name;
detectionPriority = detection.priority;
}
// Collect text_mention signal (lowest reliability — exact one member name in text)
const textMention = this.detectMemberFromMessage(msg, knownMembers);
if (textMention) {
signals.push({ member: textMention.name, source: 'text_mention' });
}
// Check toolUseResult routing (highest priority — directly identifies the agent)
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
// Collect routing_sender signal (high reliability — identifies the actual agent)
if (msg.toolUseResult && typeof msg.toolUseResult === 'object') {
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
| Record<string, unknown>
| undefined;
if (routing && typeof routing.sender === 'string') {
const sender = routing.sender.toLowerCase();
if (knownMembers.has(sender)) {
detectedMember = routing.sender;
detectionPriority = 3;
signals.push({ member: routing.sender, source: 'routing_sender' });
}
}
}
// Check process.team.memberName from system messages (highest priority)
if (detectionPriority < 3) {
const init = msg.init as Record<string, unknown> | undefined;
const process = (msg.process ?? init?.process) as Record<string, unknown> | undefined;
const team = process?.team as Record<string, unknown> | undefined;
if (team && typeof team.memberName === 'string') {
const memberNameLower = team.memberName.trim().toLowerCase();
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
detectedMember = team.memberName.trim();
detectionPriority = 3;
}
// Collect process_team signal (highest reliability — from system init message)
const init = msg.init as Record<string, unknown> | undefined;
const process = (msg.process ?? init?.process) as Record<string, unknown> | undefined;
const team = process?.team as Record<string, unknown> | undefined;
if (team && typeof team.memberName === 'string') {
const memberNameLower = team.memberName.trim().toLowerCase();
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
signals.push({ member: team.memberName.trim(), source: 'process_team' });
}
}
} catch {
@ -1056,9 +1069,25 @@ export class TeamMemberLogsFinder {
}
}
if (!detectedMember) return null;
if (signals.length === 0) return null;
return { detectedMember, description, firstTimestamp };
const best = TeamMemberLogsFinder.selectBestSignal(signals);
if (!best) return null;
return { detectedMember: best.member, description, firstTimestamp };
}
/**
* Select the best detection signal by precedence.
* Signals are collected in file order, so find() returns the earliest occurrence
* of the highest-precedence source.
*/
private static selectBestSignal(signals: DetectionSignal[]): DetectionSignal | null {
for (const source of SIGNAL_PRECEDENCE) {
const match = signals.find((s) => s.source === source);
if (match) return match;
}
return null;
}
/**

View file

@ -168,6 +168,157 @@ describe('TeamMemberLogsFinder', () => {
}
});
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);