From 0e846506025b71659767b21cd5e50e4c8cb0cfd3 Mon Sep 17 00:00:00 2001 From: iliya Date: Sat, 14 Mar 2026 14:34:56 +0200 Subject: [PATCH] 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. --- .../services/team/TeamMemberLogsFinder.ts | 97 +++++++---- .../team/TeamMemberLogsFinder.test.ts | 151 ++++++++++++++++++ 2 files changed, 214 insertions(+), 34 deletions(-) diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 3a8d8e87..a01401be 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -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 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; @@ -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(' 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).routing as | Record | 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 | undefined; - const process = (msg.process ?? init?.process) as Record | undefined; - const team = process?.team as Record | 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 | undefined; + const process = (msg.process ?? init?.process) as Record | undefined; + const team = process?.team as Record | 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; } /** diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 3d65d774..16b70e1c 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -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: + 'You are carol, a developer.', + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + type: 'assistant', + message: { role: 'assistant', content: [{ type: 'text', text: 'Working on it' }] }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:03.000Z', + toolUseResult: { routing: { sender: 'carol' } }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const carolLogs = await finder.findMemberLogs(teamName, 'carol'); + + expect(carolLogs).toHaveLength(1); + expect(carolLogs[0]?.kind).toBe('subagent'); + if (carolLogs[0]?.kind === 'subagent') { + expect(carolLogs[0].subagentId).toBe('carol01'); + expect(carolLogs[0].description).toBe('Fix button layout'); + } + }); + + it('process.team.memberName overrides teammate_id and text_mention', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); + setClaudeBasePathOverride(tmpDir); + + const teamName = 'tB'; + const projectPath = '/Users/test/projB'; + const projectId = '-Users-test-projB'; + const leadSessionId = 'sB'; + + await fs.mkdir(path.join(tmpDir, 'teams', teamName), { recursive: true }); + await fs.writeFile( + path.join(tmpDir, 'teams', teamName, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', agentType: 'general-purpose' }, + { name: 'bob', agentType: 'general-purpose' }, + ], + }), + 'utf8' + ); + + const projectRoot = path.join(tmpDir, 'projects', projectId); + await fs.mkdir(path.join(projectRoot, leadSessionId, 'subagents'), { recursive: true }); + + // Lead session file + await fs.writeFile( + path.join(projectRoot, `${leadSessionId}.jsonl`), + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'user', + message: { role: 'user', content: 'Start' }, + }) + '\n', + 'utf8' + ); + + // Subagent file: teammate_id="alice" but process.team.memberName="bob" + await fs.writeFile( + path.join(projectRoot, leadSessionId, 'subagents', 'agent-bob01.jsonl'), + [ + JSON.stringify({ + timestamp: '2026-01-01T00:00:01.000Z', + type: 'user', + message: { + role: 'user', + content: + 'Do the work', + }, + }), + JSON.stringify({ + timestamp: '2026-01-01T00:00:02.000Z', + process: { team: { memberName: 'bob' } }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + const bobLogs = await finder.findMemberLogs(teamName, 'bob'); + + expect(bobLogs).toHaveLength(1); + expect(bobLogs[0]?.kind).toBe('subagent'); + if (bobLogs[0]?.kind === 'subagent') { + expect(bobLogs[0].subagentId).toBe('bob01'); + } + + // Verify alice does NOT get this file + const aliceLogs = await finder.findMemberLogs(teamName, 'alice'); + const aliceSubagents = aliceLogs.filter((l) => l.kind === 'subagent'); + expect(aliceSubagents).toHaveLength(0); + }); + it('reports accurate messageCount from full file (not limited by scan lines)', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-')); setClaudeBasePathOverride(tmpDir);