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:
parent
e92d4658f4
commit
0e84650602
2 changed files with 214 additions and 34 deletions
|
|
@ -25,6 +25,28 @@ const ATTRIBUTION_SCAN_LINES = 50;
|
||||||
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
const TASK_SINCE_GRACE_MS = 2 * 60 * 1000;
|
||||||
const FILE_MENTIONS_CACHE_MAX = 200;
|
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 {
|
interface StreamedMetadata {
|
||||||
firstTimestamp: string | null;
|
firstTimestamp: string | null;
|
||||||
lastTimestamp: string | null;
|
lastTimestamp: string | null;
|
||||||
|
|
@ -936,6 +958,9 @@ export class TeamMemberLogsFinder {
|
||||||
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
|
* Phase 1: Scan first ATTRIBUTION_SCAN_LINES lines for member detection signals
|
||||||
* and extract a human-readable description from the first user message.
|
* and extract a human-readable description from the first user message.
|
||||||
* Returns null if the file is a warmup session or empty.
|
* 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(
|
private async attributeSubagent(
|
||||||
filePath: string,
|
filePath: string,
|
||||||
|
|
@ -969,16 +994,13 @@ export class TeamMemberLogsFinder {
|
||||||
if (lines.length === 0) return null;
|
if (lines.length === 0) return null;
|
||||||
|
|
||||||
let description = '';
|
let description = '';
|
||||||
let detectedMember: string | null = null;
|
const signals: DetectionSignal[] = [];
|
||||||
let detectionPriority = 0;
|
|
||||||
let firstTimestamp: string | null = null;
|
let firstTimestamp: string | null = null;
|
||||||
|
|
||||||
for (const line of lines) {
|
for (const line of lines) {
|
||||||
if (!firstTimestamp) {
|
if (!firstTimestamp) {
|
||||||
firstTimestamp = this.extractTimestampFromLine(line);
|
firstTimestamp = this.extractTimestampFromLine(line);
|
||||||
}
|
}
|
||||||
// Early exit: both objectives met (member detected at max priority + description found)
|
|
||||||
if (detectionPriority >= 3 && description) break;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
|
@ -991,7 +1013,7 @@ export class TeamMemberLogsFinder {
|
||||||
return null;
|
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 (role === 'user' && textContent) {
|
||||||
if (textContent.trimStart().startsWith('<teammate-message')) {
|
if (textContent.trimStart().startsWith('<teammate-message')) {
|
||||||
const parsed = parseAllTeammateMessages(textContent);
|
const parsed = parseAllTeammateMessages(textContent);
|
||||||
|
|
@ -1001,12 +1023,11 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// teammate_id identifies the MESSAGE SENDER (e.g. "team-lead"), not the agent
|
// 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.
|
// owning this file. Collected as a signal — higher-precedence sources override.
|
||||||
if (detectionPriority < 2 && parsed[0]?.teammateId) {
|
if (parsed[0]?.teammateId) {
|
||||||
const tmId = parsed[0].teammateId.trim().toLowerCase();
|
const tmId = parsed[0].teammateId.trim().toLowerCase();
|
||||||
if (tmId.length > 0 && knownMembers.has(tmId)) {
|
if (tmId.length > 0 && knownMembers.has(tmId)) {
|
||||||
detectedMember = parsed[0].teammateId.trim();
|
signals.push({ member: parsed[0].teammateId.trim(), source: 'teammate_id' });
|
||||||
detectionPriority = 2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!description) {
|
} else if (!description) {
|
||||||
|
|
@ -1014,41 +1035,33 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Multi-signal member detection ---
|
// Collect text_mention signal (lowest reliability — exact one member name in text)
|
||||||
// Higher priority signals override lower priority ones (skip if already at max)
|
const textMention = this.detectMemberFromMessage(msg, knownMembers);
|
||||||
if (detectionPriority < 3) {
|
if (textMention) {
|
||||||
const detection = this.detectMemberFromMessage(msg, knownMembers);
|
signals.push({ member: textMention.name, source: 'text_mention' });
|
||||||
if (detection && detection.priority > detectionPriority) {
|
|
||||||
detectedMember = detection.name;
|
|
||||||
detectionPriority = detection.priority;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check toolUseResult routing (highest priority — directly identifies the agent)
|
// Collect routing_sender signal (high reliability — identifies the actual agent)
|
||||||
if (detectionPriority < 3 && msg.toolUseResult && typeof msg.toolUseResult === 'object') {
|
if (msg.toolUseResult && typeof msg.toolUseResult === 'object') {
|
||||||
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
|
const routing = (msg.toolUseResult as Record<string, unknown>).routing as
|
||||||
| Record<string, unknown>
|
| Record<string, unknown>
|
||||||
| undefined;
|
| undefined;
|
||||||
if (routing && typeof routing.sender === 'string') {
|
if (routing && typeof routing.sender === 'string') {
|
||||||
const sender = routing.sender.toLowerCase();
|
const sender = routing.sender.toLowerCase();
|
||||||
if (knownMembers.has(sender)) {
|
if (knownMembers.has(sender)) {
|
||||||
detectedMember = routing.sender;
|
signals.push({ member: routing.sender, source: 'routing_sender' });
|
||||||
detectionPriority = 3;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check process.team.memberName from system messages (highest priority)
|
// Collect process_team signal (highest reliability — from system init message)
|
||||||
if (detectionPriority < 3) {
|
const init = msg.init as Record<string, unknown> | undefined;
|
||||||
const init = msg.init as Record<string, unknown> | undefined;
|
const process = (msg.process ?? init?.process) 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;
|
||||||
const team = process?.team as Record<string, unknown> | undefined;
|
if (team && typeof team.memberName === 'string') {
|
||||||
if (team && typeof team.memberName === 'string') {
|
const memberNameLower = team.memberName.trim().toLowerCase();
|
||||||
const memberNameLower = team.memberName.trim().toLowerCase();
|
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
|
||||||
if (memberNameLower.length > 0 && knownMembers.has(memberNameLower)) {
|
signals.push({ member: team.memberName.trim(), source: 'process_team' });
|
||||||
detectedMember = team.memberName.trim();
|
|
||||||
detectionPriority = 3;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -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 () => {
|
it('reports accurate messageCount from full file (not limited by scan lines)', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue