diff --git a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts index bbb52f55..4dcbaf4e 100644 --- a/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts +++ b/src/features/member-log-stream/core/domain/policies/__tests__/memberLogPreviewExtractor.test.ts @@ -273,6 +273,37 @@ Reply to this comment using MCP tool task_add_comment. expect(result.items).toEqual([]); }); + it('skips structured non-human user-role messages for inbound text extraction', () => { + const result = extractMemberLogPreviewItems({ + provider: 'opencode_runtime', + maxItems: 3, + textLimit: 160, + messages: [ + message({ + uuid: 'teammate-protocol', + type: 'user', + role: 'user', + protocolKind: 'teammate-message', + origin: { kind: 'teammate' }, + isSynthetic: true, + timestamp: '2026-04-01T10:00:00.000Z', + content: 'Looks good', + }), + message({ + uuid: 'coordinator', + type: 'user', + role: 'user', + origin: { kind: 'coordinator' }, + isSynthetic: true, + timestamp: '2026-04-01T10:01:00.000Z', + content: 'Human: I tested the feature looks good', + }), + ], + }); + + expect(result.items).toEqual([]); + }); + it('extracts tool_use input and tool_result output without rendering huge payloads', () => { const hugeOutput = 'x'.repeat(10_000); const result = extractMemberLogPreviewItems({ diff --git a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts index 4c6121a4..ae0a772d 100644 --- a/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts +++ b/src/features/member-log-stream/core/domain/policies/memberLogPreviewExtractor.ts @@ -1,3 +1,5 @@ +import { isHumanAuthoredUserTurn, type MessageOriginLike } from '@shared/utils/userTurnProvenance'; + import type { MemberLogPreviewItem, MemberLogPreviewItemKind, @@ -20,6 +22,10 @@ export interface MemberLogPreviewParsedMessage { timestamp: Date | string; content: string | MemberLogPreviewContentBlock[]; isMeta?: boolean; + isSynthetic?: boolean; + isReplay?: boolean; + origin?: MessageOriginLike; + protocolKind?: string; toolCalls?: readonly { id?: string; name?: string; @@ -2065,13 +2071,6 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string { return message.role ?? message.type ?? ''; } -function messageHasToolResult(message: MemberLogPreviewParsedMessage): boolean { - if ((message.toolResults?.length ?? 0) > 0) { - return true; - } - return Array.isArray(message.content) && message.content.some(isToolResultBlock); -} - function buildItemId(input: { provider: MemberLogStreamProvider; sourceId: string; @@ -2419,7 +2418,7 @@ export function extractMemberLogPreviewItems( } } - if (role === 'user' && message.isMeta !== true && !messageHasToolResult(message)) { + if (role === 'user' && isHumanAuthoredUserTurn(message)) { const inboundPreview = extractInboundTextPreview(message.content, textLimit); if (inboundPreview) { candidates.push( diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index c6fc54d9..d6bdd1c6 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -9,6 +9,7 @@ import { type EnhancedAIChunk, type EnhancedChunk, isEnhancedAIChunk, + isHumanAuthoredParsedUserMessage, type ParsedMessage, type Process, type SemanticStepGroup, @@ -85,19 +86,7 @@ export async function buildSubagentDetail( // Build chunks with semantic steps const chunks = buildChunksFn(parsedSession.messages, nestedSubagents); - // Extract description (try to get from first user message) - let description = 'Subagent'; - if (parsedSession.messages.length > 0) { - const firstUserMsg = parsedSession.messages.find( - (m) => m.type === 'user' && typeof m.content === 'string' - ); - if (firstUserMsg && typeof firstUserMsg.content === 'string') { - description = firstUserMsg.content.substring(0, 100); - if (firstUserMsg.content.length > 100) { - description += '...'; - } - } - } + const description = deriveSubagentDescription(parsedSession.messages); // Calculate timing const times = parsedSession.messages.map((m) => m.timestamp.getTime()); @@ -144,3 +133,19 @@ export async function buildSubagentDetail( return null; } } + +export function deriveSubagentDescription(messages: ParsedMessage[]): string { + const firstUserMsg = messages.find((message) => { + return isHumanAuthoredParsedUserMessage(message) && typeof message.content === 'string'; + }); + + if (!firstUserMsg || typeof firstUserMsg.content !== 'string') { + return 'Subagent'; + } + + let description = firstUserMsg.content.substring(0, 100); + if (firstUserMsg.content.length > 100) { + description += '...'; + } + return description; +} diff --git a/src/main/services/discovery/SessionContentFilter.ts b/src/main/services/discovery/SessionContentFilter.ts index 64181188..7e4bd78d 100644 --- a/src/main/services/discovery/SessionContentFilter.ts +++ b/src/main/services/discovery/SessionContentFilter.ts @@ -21,9 +21,17 @@ * - synthetic assistant messages (model='') */ +import { + HARD_NOISE_TAGS, +} from '@main/constants/messageTags'; import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; import { type ChatHistoryEntry, type ContentBlock } from '@main/types'; import { createLogger } from '@shared/utils/logger'; +import { + classifyUserTurnProvenance, + isDisplayableTeammateProtocol, + isSyntheticReplayNoise, +} from '@shared/utils/userTurnProvenance'; import * as readline from 'readline'; import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider'; @@ -40,11 +48,6 @@ function byteLen(chunk: string): number { return Buffer.byteLength(chunk, 'utf8'); } -/** - * Hard noise tags - user messages with ONLY these tags are filtered out. - */ -const HARD_NOISE_TAGS = ['', '']; - /** * Hard noise entry types - these types are always filtered out. */ @@ -193,10 +196,30 @@ export class SessionContentFilter { const userEntry = entry as { message?: { content?: string | ContentBlock[] }; isMeta?: boolean; + isSynthetic?: boolean; + isReplay?: boolean; + toolUseResult?: unknown; + sourceToolUseID?: unknown; + origin?: { kind?: string }; + protocolKind?: string; }; const content = userEntry.message?.content; const isMeta = userEntry.isMeta; + if (isSyntheticReplayNoise(userEntry)) { + return false; + } + + const provenance = classifyUserTurnProvenance(userEntry); + if ( + provenance !== 'human' && + provenance !== 'tool-result' && + provenance !== 'local-command-output' && + !isDisplayableTeammateProtocol(userEntry) + ) { + return false; + } + // Internal user messages (tool results) - part of AI response flow // These ARE displayable as they're part of AIChunks if (isMeta === true) { diff --git a/src/main/services/discovery/SubagentResolver.ts b/src/main/services/discovery/SubagentResolver.ts index 240e8e80..38055c4f 100644 --- a/src/main/services/discovery/SubagentResolver.ts +++ b/src/main/services/discovery/SubagentResolver.ts @@ -9,9 +9,16 @@ * - Link subagents to parent Task tool calls */ -import { type ParsedMessage, type Process, type SessionMetrics, type ToolCall } from '@main/types'; +import { + isHumanAuthoredParsedUserMessage, + type ParsedMessage, + type Process, + type SessionMetrics, + type ToolCall, +} from '@main/types'; import { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl'; import { createLogger } from '@shared/utils/logger'; +import { isDisplayableTeammateProtocol } from '@shared/utils/userTurnProvenance'; import * as path from 'path'; import { type ProjectScanner } from './ProjectScanner'; @@ -142,8 +149,9 @@ export class SubagentResolver { * - isSidechain: true (all subagents have this) */ private isWarmupSubagent(messages: ParsedMessage[]): boolean { - // Find the first user message - const firstUserMessage = messages.find((m) => m.type === 'user'); + // Find the first authored user message. Synthetic SDK replays can also be + // user-role rows, but they must not decide whether a subagent is warmup. + const firstUserMessage = messages.find((m) => this.isAuthoredUserMessage(m)); if (!firstUserMessage) { return false; } @@ -158,12 +166,40 @@ export class SubagentResolver { * Used for deterministic matching of team member files to their spawning Task calls. */ private extractTeammateId(messages: ParsedMessage[]): string | undefined { - const firstUserMessage = messages.find((m) => m.type === 'user'); - if (!firstUserMessage) return undefined; + for (const message of messages) { + if (!this.isAuthoredUserMessage(message)) continue; - const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : ''; - const match = /]*?\bteammate_id="([^"]+)"/.exec(text); - return match?.[1]; + const text = this.extractUserText(message); + const normalized = this.stripTranscriptSpeakerPrefix(text); + const match = /]*?\bteammate_id="([^"]+)"/.exec(normalized); + if (match?.[1]) { + return match[1]; + } + } + + return undefined; + } + + private isAuthoredUserMessage(message: ParsedMessage): boolean { + return ( + isHumanAuthoredParsedUserMessage(message) || + isDisplayableTeammateProtocol(message) + ); + } + + private extractUserText(message: ParsedMessage): string { + if (typeof message.content === 'string') { + return message.content; + } + + return message.content + .filter((block) => block.type === 'text') + .map((block) => block.text) + .join('\n'); + } + + private stripTranscriptSpeakerPrefix(text: string): string { + return text.replace(/^(?:Human|User):\s*/i, '').trimStart(); } /** diff --git a/src/main/services/parsing/SessionParser.ts b/src/main/services/parsing/SessionParser.ts index 3219f4b6..a3844a7b 100644 --- a/src/main/services/parsing/SessionParser.ts +++ b/src/main/services/parsing/SessionParser.ts @@ -9,6 +9,7 @@ */ import { + isHumanAuthoredParsedUserMessage, isParsedInternalUserMessage, isParsedRealUserMessage, type ParsedMessage, @@ -178,8 +179,9 @@ export class SessionParser { for (let i = userMsgIndex + 1; i < messages.length; i++) { const msg = messages[i]; - // Stop at next user message - if (msg.type === 'user') break; + // Stop at the next human-authored user turn. Structured protocol/meta + // rows can use type='user' but must not split the assistant response. + if (isHumanAuthoredParsedUserMessage(msg)) break; // Include assistant responses if (msg.type === 'assistant') { diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index a8b6f49b..9c99cd54 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -1,6 +1,10 @@ import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; import { createLogger } from '@shared/utils/logger'; import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; +import { + isDisplayableTeammateProtocol, + isHumanAuthoredUserTurn, +} from '@shared/utils/userTurnProvenance'; import { createReadStream } from 'fs'; import * as fs from 'fs/promises'; import * as path from 'path'; @@ -1831,16 +1835,17 @@ export class TeamMemberLogsFinder { try { const msg = JSON.parse(line) as Record; - const role = this.extractRole(msg); const textContent = this.extractTextContent(msg); + const isAuthoredUserText = this.isAuthoredUserTextEntry(msg); + // Skip warmup messages - if (role === 'user' && textContent?.trim() === 'Warmup') { + if (isAuthoredUserText && textContent?.trim() === 'Warmup') { return null; } // Extract description from first user message + collect teammate_id signal - if (role === 'user' && textContent) { + if (isAuthoredUserText && textContent) { if (textContent.trimStart().startsWith(' 0 && @@ -2052,7 +2059,7 @@ export class TeamMemberLogsFinder { msg: Record, knownMembers: Set ): { name: string; priority: number } | null { - if (this.extractRole(msg) !== 'user') return null; + if (!this.isAuthoredUserTextEntry(msg)) return null; const text = this.extractTextContent(msg); if (!text) return null; @@ -2073,6 +2080,11 @@ export class TeamMemberLogsFinder { return null; } + private isAuthoredUserTextEntry(msg: Record): boolean { + if (this.extractRole(msg) !== 'user') return false; + return isHumanAuthoredUserTurn(msg) || isDisplayableTeammateProtocol(msg); + } + private extractTextContent(msg: Record): string | null { if (typeof msg.content === 'string') { return msg.content; diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index 96b9b730..90c3667e 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -162,6 +162,10 @@ interface ConversationalEntry extends BaseEntry { */ export type ToolUseResultData = Record; +export interface MessageOrigin { + kind?: string; +} + /** * CRITICAL: User entries serve two purposes: * @@ -182,6 +186,10 @@ export interface UserEntry extends ConversationalEntry { type: 'user'; message: UserMessage; isMeta?: boolean; + isSynthetic?: boolean; + isReplay?: boolean; + origin?: MessageOrigin; + protocolKind?: string; agentId?: string; toolUseResult?: ToolUseResultData; diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index dbcaa44a..889a1e61 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -6,6 +6,14 @@ * parsed messages into categories for chunk building. */ +import { + classifyUserTurnProvenance, + isDisplayableTeammateProtocol, + isHumanAuthoredUserTurn, + isSyntheticReplayNoise, + type MessageOriginLike, +} from '@shared/utils/userTurnProvenance'; + import { EMPTY_STDERR, EMPTY_STDOUT, @@ -92,6 +100,14 @@ export interface ParsedMessage { isSidechain: boolean; /** Whether this is a meta message */ isMeta: boolean; + /** Whether this user-role row is a synthetic/replayed SDK event, not human-authored input */ + isSynthetic?: boolean; + /** Whether this user-role row acknowledges a previously accepted turn */ + isReplay?: boolean; + /** Structured source of a user-role row. Missing means legacy/human candidate. */ + origin?: MessageOriginLike; + /** Structured protocol payload kind. Missing means legacy fallback. */ + protocolKind?: string; /** User type ("external" for user input) */ userType?: string; // Extracted tool information @@ -140,8 +156,7 @@ export interface ParsedMessage { * be treated as system responses, not user input that starts new chunks. */ export function isParsedRealUserMessage(msg: ParsedMessage): boolean { - if (msg.type !== 'user') return false; - if (msg.isMeta) return false; + if (!isHumanAuthoredParsedUserMessage(msg)) return false; const content = msg.content; @@ -180,9 +195,7 @@ export function isParsedRealUserMessage(msg: ParsedMessage): boolean { * - "..." -> Hard noise */ export function isParsedUserChunkMessage(msg: ParsedMessage): boolean { - if (msg.type !== 'user') return false; - if (msg.isMeta === true) return false; - if (isParsedTeammateMessage(msg)) return false; + if (!isHumanAuthoredParsedUserMessage(msg)) return false; const content = msg.content; @@ -273,7 +286,10 @@ export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean { // Array content - check text blocks if (Array.isArray(content)) { return content.some( - (block) => block.type === 'text' && block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG) + (block) => + block.type === 'text' && + (block.text.startsWith(LOCAL_COMMAND_STDOUT_TAG) || + block.text.startsWith(LOCAL_COMMAND_STDERR_TAG)) ); } @@ -333,6 +349,24 @@ export function isParsedHardNoiseMessage(msg: ParsedMessage): boolean { if (msg.type === 'user') { const content = msg.content; + if (msg.isCompactSummary === true) { + return false; + } + + if (isSyntheticReplayNoise(msg)) { + return true; + } + + const provenance = classifyUserTurnProvenance(msg); + if ( + provenance !== 'human' && + provenance !== 'tool-result' && + provenance !== 'local-command-output' && + !isDisplayableTeammateProtocol(msg) + ) { + return true; + } + if (typeof content === 'string') { // Check if content contains ONLY noise tags (trim whitespace) const trimmedContent = content.trim(); @@ -404,20 +438,6 @@ export function isParsedCompactMessage(msg: ParsedMessage): boolean { return msg.isCompactSummary === true; } -/** - * Detect teammate messages - messages from team member agents. - * Format: content - */ -const TEAMMATE_MESSAGE_REGEX = /^ block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim()) - ); - } - return false; +export function isHumanAuthoredParsedUserMessage(msg: ParsedMessage): boolean { + return msg.type === 'user' && isHumanAuthoredUserTurn(msg); } diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 480235d1..f56d2e9d 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -341,6 +341,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let codexNativeExecutableVersion: string | null | undefined; let isSidechain = false; let isMeta = false; + let isSynthetic: boolean | undefined; + let isReplay: boolean | undefined; + let origin: { kind?: string } | undefined; + let protocolKind: string | undefined; let userType: string | undefined; let sourceToolUseID: string | undefined; let sourceToolAssistantUUID: string | undefined; @@ -364,7 +368,11 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { content = entry.message.content ?? ''; role = entry.message.role; agentId = entry.agentId; - isMeta = entry.isMeta ?? false; + isSynthetic = entry.isSynthetic; + isReplay = entry.isReplay; + origin = entry.origin; + protocolKind = entry.protocolKind; + isMeta = (entry.isMeta ?? false) || entry.isSynthetic === true; sourceToolUseID = entry.sourceToolUseID; sourceToolAssistantUUID = entry.sourceToolAssistantUUID; toolUseResult = entry.toolUseResult; @@ -415,6 +423,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { agentName, isSidechain, isMeta, + isSynthetic, + isReplay, + origin, + protocolKind, userType, isCompactSummary, level, @@ -741,8 +753,8 @@ export async function analyzeSessionFileMetadata( model = parsed.model ?? model; } - if (!firstUserMessage && entry.type === 'user') { - const content = entry.message?.content; + if (!firstUserMessage && parsed.type === 'user' && isParsedUserChunkMessage(parsed)) { + const content = parsed.content; if (typeof content === 'string') { if (isCommandOutputContent(content)) { // Skip diff --git a/src/main/utils/metadataExtraction.ts b/src/main/utils/metadataExtraction.ts index 4e9c95c4..278f8edb 100644 --- a/src/main/utils/metadataExtraction.ts +++ b/src/main/utils/metadataExtraction.ts @@ -4,6 +4,7 @@ import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; +import { isHumanAuthoredUserTurn } from '@shared/utils/userTurnProvenance'; import * as fs from 'fs/promises'; import * as readline from 'readline'; @@ -283,6 +284,10 @@ export async function extractFirstUserMessagePreview( } function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null { + if (!isHumanAuthoredUserTurn(entry)) { + return null; + } + const timestamp = entry.timestamp ?? new Date().toISOString(); const message = entry.message; if (!message) { diff --git a/src/renderer/utils/displayItemBuilder.ts b/src/renderer/utils/displayItemBuilder.ts index 5b6667ed..05eb8691 100644 --- a/src/renderer/utils/displayItemBuilder.ts +++ b/src/renderer/utils/displayItemBuilder.ts @@ -5,6 +5,10 @@ */ import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser'; +import { + isDisplayableTeammateProtocol, + isHumanAuthoredUserTurn, +} from '@shared/utils/userTurnProvenance'; import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers'; import { extractSlashes, type PrecedingSlashInfo } from './slashCommandExtractor'; @@ -235,7 +239,7 @@ export function buildDisplayItems( // Add teammate messages from responses (one user message may contain multiple blocks) if (responses) { for (const msg of responses) { - if (msg.type !== 'user' || msg.isMeta) continue; + if (!isDisplayableTeammateProtocol(msg)) continue; const rawText = typeof msg.content === 'string' ? msg.content @@ -401,7 +405,7 @@ export function buildDisplayItemsFromMessages( // Check for teammate messages (non-meta user messages with content) // One user message may contain multiple blocks - if (msg.type === 'user' && !msg.isMeta) { + if (msg.type === 'user' && (isHumanAuthoredUserTurn(msg) || isDisplayableTeammateProtocol(msg))) { const rawText = typeof msg.content === 'string' ? msg.content @@ -411,7 +415,9 @@ export function buildDisplayItemsFromMessages( .map((b) => b.text) .join('') : ''; - const parsedBlocks = parseAllTeammateMessages(rawText); + const parsedBlocks = isDisplayableTeammateProtocol(msg) + ? parseAllTeammateMessages(rawText) + : []; if (parsedBlocks.length > 0) { for (const parsed of parsedBlocks) { displayItems.push({ diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts index 0a4217bd..10063abd 100644 --- a/src/renderer/utils/sessionAnalyzer.ts +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -23,6 +23,7 @@ import { detectSwitchPattern, } from '@renderer/utils/reportAssessments'; import { calculateMessageCost } from '@shared/utils/pricing'; +import { isHumanAuthoredUserTurn } from '@shared/utils/userTurnProvenance'; import type { AgentTreeNode, @@ -354,6 +355,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { for (let i = 0; i < messages.length; i++) { const m = messages[i]; const msgType = m.type ?? 'unknown'; + const isHumanUserMessage = msgType === 'user' && isHumanAuthoredUserTurn(m); typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1); const msgUuid = m.uuid ?? ''; const msgParent = m.parentUuid ?? ''; @@ -473,7 +475,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { if (msgType === 'assistant' && msgTs) { lastAssistantTs = msgTs; } - if (msgType === 'user' && msgTs && lastAssistantTs) { + if (isHumanUserMessage && msgTs && lastAssistantTs) { const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000; if (gap > IDLE_THRESHOLD_SEC) { idleGaps.push({ @@ -485,7 +487,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { } // --- First user message length (prompt quality) --- - if (msgType === 'user' && !firstUserSeen && !m.isMeta) { + if (isHumanUserMessage && !firstUserSeen) { const contentText = extractTextContent(m); if (contentText.trim()) { firstUserMessageLength = contentText.length; @@ -617,7 +619,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { let label: string | null = null; if (msgType === 'user' && typeof m.content === 'string') { const content = m.content; - if (content.includes('start feature')) { + if (isHumanUserMessage && content.includes('start feature')) { label = `User: ${content.slice(0, 60)}`; } else if (content.includes('being continued')) { label = 'Context compaction/continuation'; @@ -639,7 +641,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { } // --- Friction signals (user messages) --- - if (msgType === 'user' && !m.isMeta) { + if (isHumanUserMessage) { const contentText = extractTextContent(m); if (contentText.trim()) { userMessageCount++; diff --git a/src/shared/utils/userTurnProvenance.ts b/src/shared/utils/userTurnProvenance.ts new file mode 100644 index 00000000..e7f1ac47 --- /dev/null +++ b/src/shared/utils/userTurnProvenance.ts @@ -0,0 +1,257 @@ +export type UserTurnProvenanceKind = + | 'human' + | 'synthetic-replay' + | 'coordinator' + | 'teammate-protocol' + | 'task-notification' + | 'channel' + | 'cross-session' + | 'tick' + | 'tool-result' + | 'local-command-output'; + +export interface MessageOriginLike { + kind?: string; +} + +export interface UserTurnProvenanceInput { + type?: string; + isMeta?: boolean; + isSynthetic?: boolean; + isReplay?: boolean; + isCompactSummary?: boolean; + toolUseResult?: unknown; + sourceToolUseID?: unknown; + origin?: MessageOriginLike; + protocolKind?: string; + content?: unknown; + message?: { + content?: unknown; + }; + toolResults?: readonly unknown[]; +} + +const LOCAL_COMMAND_STDOUT_TAG = 'local-command-stdout'; +const LOCAL_COMMAND_STDERR_TAG = 'local-command-stderr'; +const BASH_STDOUT_TAG = 'bash-stdout'; +const BASH_STDERR_TAG = 'bash-stderr'; +const TEAMMATE_MESSAGE_TAG = 'teammate-message'; +const TASK_NOTIFICATION_TAG = 'task-notification'; +const CHANNEL_MESSAGE_TAG = 'channel-message'; +const CROSS_SESSION_MESSAGE_TAG = 'cross-session-message'; +const TICK_TAG = 'tick'; + +export function classifyUserTurnProvenance( + message: UserTurnProvenanceInput +): UserTurnProvenanceKind { + if (hasToolResultProvenance(message)) { + return 'tool-result'; + } + + if (hasSystemOutputContent(getMessageContent(message))) { + return 'local-command-output'; + } + + if (message.isCompactSummary === true) { + return 'synthetic-replay'; + } + + const protocolKind = normalizeProtocolKind(message.protocolKind); + if (protocolKind) { + return protocolKind; + } + + const originKind = normalizeOriginKind(message.origin); + if (originKind) { + return originKind; + } + + const legacyProtocolKind = classifyLegacyProtocolText( + getTextContent(getMessageContent(message)) + ); + if (legacyProtocolKind) { + return legacyProtocolKind; + } + + if (message.isSynthetic === true) { + return 'synthetic-replay'; + } + + if (message.isMeta === true) { + return 'coordinator'; + } + + return 'human'; +} + +export function isHumanAuthoredUserTurn(message: UserTurnProvenanceInput): boolean { + return classifyUserTurnProvenance(message) === 'human'; +} + +export function isSyntheticReplayNoise(message: UserTurnProvenanceInput): boolean { + return ( + message.isSynthetic === true && + message.isReplay === true && + !hasToolResultPayload(message) && + !hasSystemOutputContent(getMessageContent(message)) + ); +} + +export function isDisplayableTeammateProtocol( + message: UserTurnProvenanceInput +): boolean { + return ( + classifyUserTurnProvenance(message) === 'teammate-protocol' && + message.isMeta !== true && + message.isSynthetic !== true + ); +} + +function normalizeProtocolKind( + protocolKind: string | undefined +): UserTurnProvenanceKind | undefined { + switch (protocolKind) { + case TEAMMATE_MESSAGE_TAG: + return 'teammate-protocol'; + case TASK_NOTIFICATION_TAG: + return 'task-notification'; + case CHANNEL_MESSAGE_TAG: + return 'channel'; + case CROSS_SESSION_MESSAGE_TAG: + return 'cross-session'; + case TICK_TAG: + return 'tick'; + default: + return undefined; + } +} + +function normalizeOriginKind( + origin: MessageOriginLike | undefined +): UserTurnProvenanceKind | undefined { + switch (origin?.kind) { + case undefined: + case 'human': + return undefined; + case 'task-notification': + return 'task-notification'; + case 'channel': + return 'channel'; + case 'cross-session': + return 'cross-session'; + case 'tick': + return 'tick'; + case 'teammate': + return 'teammate-protocol'; + case 'coordinator': + default: + return 'coordinator'; + } +} + +function classifyLegacyProtocolText( + text: string | undefined +): UserTurnProvenanceKind | undefined { + if (!text) { + return undefined; + } + + const normalized = stripTranscriptSpeakerPrefix(text.trimStart()); + if (normalized.startsWith(`<${TEAMMATE_MESSAGE_TAG}`)) { + return 'teammate-protocol'; + } + if (normalized.startsWith(`<${TASK_NOTIFICATION_TAG}`)) { + return 'task-notification'; + } + if (normalized.startsWith(`<${CHANNEL_MESSAGE_TAG}`)) { + return 'channel'; + } + if (normalized.startsWith(`<${CROSS_SESSION_MESSAGE_TAG}`)) { + return 'cross-session'; + } + if (normalized.startsWith(`<${TICK_TAG}`)) { + return 'tick'; + } + return undefined; +} + +function stripTranscriptSpeakerPrefix(text: string): string { + return text.replace(/^(?:Human|User):\s*/i, '').trimStart(); +} + +function hasToolResultProvenance(message: UserTurnProvenanceInput): boolean { + if (message.toolUseResult !== undefined || message.sourceToolUseID !== undefined) { + return true; + } + if ((message.toolResults?.length ?? 0) > 0) { + return true; + } + return hasToolResultContent(message); +} + +function hasToolResultPayload(message: UserTurnProvenanceInput): boolean { + return ( + message.toolUseResult !== undefined || + message.sourceToolUseID !== undefined || + (message.toolResults?.length ?? 0) > 0 || + hasToolResultContent(message) + ); +} + +function hasToolResultContent(message: UserTurnProvenanceInput): boolean { + const content = getMessageContent(message); + return ( + Array.isArray(content) && + content.some((block) => isContentBlock(block) && block.type === 'tool_result') + ); +} + +function hasSystemOutputContent(content: unknown): boolean { + if (typeof content === 'string') { + return startsWithSystemOutputTag(content); + } + + return ( + Array.isArray(content) && + content.some( + (block) => + isContentBlock(block) && + block.type === 'text' && + startsWithSystemOutputTag(block.text) + ) + ); +} + +function startsWithSystemOutputTag(text: string | undefined): boolean { + if (!text) { + return false; + } + return ( + text.startsWith(`<${LOCAL_COMMAND_STDOUT_TAG}>`) || + text.startsWith(`<${LOCAL_COMMAND_STDERR_TAG}>`) || + text.startsWith(`<${BASH_STDOUT_TAG}>`) || + text.startsWith(`<${BASH_STDERR_TAG}>`) + ); +} + +function getMessageContent(message: UserTurnProvenanceInput): unknown { + return message.message?.content ?? message.content; +} + +function getTextContent(content: unknown): string | undefined { + if (typeof content === 'string') { + return content; + } + if (!Array.isArray(content)) { + return undefined; + } + return content + .filter(isContentBlock) + .filter((block) => block.type === 'text') + .map((block) => block.text ?? '') + .join('\n'); +} + +function isContentBlock(value: unknown): value is { type?: string; text?: string } { + return value !== null && typeof value === 'object'; +} diff --git a/test/main/services/analysis/ChunkBuilder.test.ts b/test/main/services/analysis/ChunkBuilder.test.ts index fb2fed2d..6ff24379 100644 --- a/test/main/services/analysis/ChunkBuilder.test.ts +++ b/test/main/services/analysis/ChunkBuilder.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'; import { ChunkBuilder } from '../../../../src/main/services/analysis/ChunkBuilder'; import { isAIChunk, isCompactChunk, isSystemChunk, isUserChunk } from '../../../../src/main/types'; + import type { ParsedMessage, Process } from '../../../../src/main/types'; // ============================================================================= @@ -350,6 +351,19 @@ describe('ChunkBuilder', () => { const chunks = builder.buildChunks(messages); expect(chunks).toHaveLength(0); }); + + it('should filter out structured coordinator user-role text', () => { + const messages = [ + createMessage({ + type: 'user', + content: 'Human: I tested the feature looks good', + origin: { kind: 'coordinator' }, + }), + ]; + + const chunks = builder.buildChunks(messages); + expect(chunks).toHaveLength(0); + }); }); describe('AIChunk flushing', () => { diff --git a/test/main/services/analysis/SubagentDetailBuilder.test.ts b/test/main/services/analysis/SubagentDetailBuilder.test.ts new file mode 100644 index 00000000..10ce8d2d --- /dev/null +++ b/test/main/services/analysis/SubagentDetailBuilder.test.ts @@ -0,0 +1,75 @@ +import { deriveSubagentDescription } from '@main/services/analysis/SubagentDetailBuilder'; +import { describe, expect, it } from 'vitest'; + +import type { ParsedMessage } from '@main/types'; + +function message(overrides: Partial): ParsedMessage { + return { + uuid: 'msg-1', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-01-01T00:00:00.000Z'), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +describe('deriveSubagentDescription', () => { + it('uses the first authored user text', () => { + expect( + deriveSubagentDescription([ + message({ type: 'assistant', content: 'assistant output' }), + message({ content: 'real subagent task' }), + ]) + ).toBe('real subagent task'); + }); + + it('ignores synthetic user replay text before real user text', () => { + expect( + deriveSubagentDescription([ + message({ + content: 'Human: I tested the feature looks good', + isMeta: true, + isReplay: true, + isSynthetic: true, + }), + message({ content: 'Implement the actual task' }), + ]) + ).toBe('Implement the actual task'); + }); + + it('falls back when no authored user text exists', () => { + expect( + deriveSubagentDescription([ + message({ + content: 'Human: I tested the feature looks good', + isMeta: true, + isReplay: true, + isSynthetic: true, + }), + ]) + ).toBe('Subagent'); + }); + + it('ignores structured protocol rows before authored text', () => { + expect( + deriveSubagentDescription([ + message({ + content: 'plain protocol payload', + protocolKind: 'teammate-message', + }), + message({ content: 'Implement the actual task' }), + ]) + ).toBe('Implement the actual task'); + }); + + it('preserves the existing 100 character truncation behavior', () => { + expect(deriveSubagentDescription([message({ content: 'a'.repeat(101) })])).toBe( + `${'a'.repeat(100)}...` + ); + }); +}); diff --git a/test/main/services/discovery/SearchTextExtractor.test.ts b/test/main/services/discovery/SearchTextExtractor.test.ts index 1d2b122c..ec1ce11d 100644 --- a/test/main/services/discovery/SearchTextExtractor.test.ts +++ b/test/main/services/discovery/SearchTextExtractor.test.ts @@ -141,6 +141,34 @@ describe('SearchTextExtractor', () => { expect(result.entries[0].text).toBe('main thread'); }); + it('does not index synthetic user-role replay text as human or AI content', () => { + const syntheticReplay: ParsedMessage = { + ...makeUserMessage('u-synthetic', 'Human: I tested the feature looks good'), + isMeta: true, + isReplay: true, + isSynthetic: true, + } as ParsedMessage; + const messages = [syntheticReplay, makeUserMessage('u1', 'real user text')]; + const result = extractSearchableEntries(messages); + + expect(result.sessionTitle).toBe('real user text'); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].messageUuid).toBe('u1'); + }); + + it('does not index structured protocol rows as human or AI content', () => { + const protocolRow: ParsedMessage = { + ...makeUserMessage('u-protocol', 'plain protocol payload'), + protocolKind: 'teammate-message', + } as ParsedMessage; + const messages = [protocolRow, makeUserMessage('u1', 'real user text')]; + const result = extractSearchableEntries(messages); + + expect(result.sessionTitle).toBe('real user text'); + expect(result.entries).toHaveLength(1); + expect(result.entries[0].messageUuid).toBe('u1'); + }); + it('extracts sessionTitle from first user message (truncated to 100 chars)', () => { const longText = 'a'.repeat(200); const messages = [ diff --git a/test/main/services/discovery/SessionContentFilter.test.ts b/test/main/services/discovery/SessionContentFilter.test.ts new file mode 100644 index 00000000..0a830bd4 --- /dev/null +++ b/test/main/services/discovery/SessionContentFilter.test.ts @@ -0,0 +1,217 @@ +import { SessionContentFilter } from '@main/services/discovery/SessionContentFilter'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { describe, expect, it } from 'vitest'; + +import type { ChatHistoryEntry } from '@main/types'; + +function userEntry(overrides: Partial): ChatHistoryEntry { + return { + uuid: 'user-1', + type: 'user', + timestamp: '2026-04-12T15:36:14.250Z', + message: { + role: 'user', + content: 'Human: I tested the feature looks good', + }, + ...overrides, + } as ChatHistoryEntry; +} + +describe('SessionContentFilter', () => { + describe('hasNonNoiseMessages', () => { + it('returns false for a file containing only synthetic user replay text', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-content-filter-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync( + filePath, + `${JSON.stringify( + userEntry({ + isReplay: true, + isSynthetic: true, + }) + )}\n`, + 'utf8' + ); + + await expect(SessionContentFilter.hasNonNoiseMessages(filePath)).resolves.toBe(false); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + + it('returns true for a file containing ordinary human replay text', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'session-content-filter-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + fs.writeFileSync( + filePath, + `${JSON.stringify( + userEntry({ + isReplay: true, + }) + )}\n`, + 'utf8' + ); + + await expect(SessionContentFilter.hasNonNoiseMessages(filePath)).resolves.toBe(true); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + }); + + describe('isDisplayableEntry', () => { + it('does not treat synthetic user text replay as displayable content', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isReplay: true, + isSynthetic: true, + }) + ) + ).toBe(false); + }); + + it('keeps synthetic tool-result rows with sourceToolUseID displayable', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isMeta: true, + isReplay: true, + isSynthetic: true, + sourceToolUseID: 'tool-1', + }) + ) + ).toBe(true); + }); + + it('keeps ordinary user text displayable even when it starts with Human', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isReplay: true, + }) + ) + ).toBe(true); + }); + + it('does not treat structured synthetic protocol replay as displayable content', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isReplay: true, + isSynthetic: true, + protocolKind: 'teammate-message', + message: { + role: 'user', + content: 'plain protocol payload', + }, + }) + ) + ).toBe(false); + }); + + it('does not treat structured synthetic protocol rows as displayable content without replay', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isMeta: true, + isSynthetic: true, + protocolKind: 'teammate-message', + message: { + role: 'user', + content: 'plain protocol payload', + }, + }) + ) + ).toBe(false); + }); + + it('does not treat structured task notifications as displayable human content', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + origin: { kind: 'task-notification' }, + protocolKind: 'task-notification', + message: { + role: 'user', + content: 'done', + }, + }) + ) + ).toBe(false); + }); + + it('keeps non-synthetic teammate protocol rows displayable for attribution', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + protocolKind: 'teammate-message', + message: { + role: 'user', + content: + 'Looks good', + }, + }) + ) + ).toBe(true); + }); + + it('keeps synthetic user tool results displayable as AI response flow', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isMeta: true, + isSynthetic: true, + sourceToolUseID: 'tool-1', + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'result text', + }, + ], + }, + }) + ) + ).toBe(true); + }); + + it('keeps non-replay synthetic meta text displayable as AI response flow', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isMeta: true, + isSynthetic: true, + sourceToolUseID: 'tool-1', + message: { + role: 'user', + content: 'Base directory for this skill: /tmp/skill', + }, + }) + ) + ).toBe(true); + }); + + it('keeps synthetic replay command output displayable', () => { + expect( + SessionContentFilter.isDisplayableEntry( + userEntry({ + isMeta: true, + isReplay: true, + isSynthetic: true, + message: { + role: 'user', + content: 'Set model to sonnet', + }, + }) + ) + ).toBe(true); + }); + }); +}); diff --git a/test/main/services/discovery/SubagentResolver.linkType.test.ts b/test/main/services/discovery/SubagentResolver.linkType.test.ts index dd894cf4..eaffda6b 100644 --- a/test/main/services/discovery/SubagentResolver.linkType.test.ts +++ b/test/main/services/discovery/SubagentResolver.linkType.test.ts @@ -10,12 +10,11 @@ * - Different description but same teammate_id still matches */ +import { SubagentResolver } from '@main/services/discovery/SubagentResolver'; import { describe, expect, it } from 'vitest'; -import { SubagentResolver } from '../../../../src/main/services/discovery/SubagentResolver'; - -import type { ParsedMessage, Process, ToolCall } from '../../../../src/main/types'; -import type { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner'; +import type { ProjectScanner } from '@main/services/discovery/ProjectScanner'; +import type { ParsedMessage, Process, ToolCall } from '@main/types'; // ============================================================================= // Helpers @@ -68,6 +67,14 @@ function extractTaskCalls(messages: ParsedMessage[]): ToolCall[] { return calls; } +type LinkToTaskCalls = ( + subagents: Process[], + taskCalls: ToolCall[], + messages: ParsedMessage[] +) => void; +type IsWarmupSubagent = (messages: ParsedMessage[]) => boolean; +type PropagateTeamMetadata = (subagents: Process[]) => void; + // ============================================================================= // Tests // ============================================================================= @@ -77,8 +84,42 @@ describe('SubagentResolver.linkType', () => { // Access private method via prototype for testing const linkToTaskCalls = ( - resolver as unknown as { linkToTaskCalls: Function } + resolver as unknown as { linkToTaskCalls: LinkToTaskCalls } ).linkToTaskCalls.bind(resolver); + const isWarmupSubagent = ( + resolver as unknown as { isWarmupSubagent: IsWarmupSubagent } + ).isWarmupSubagent.bind(resolver); + + describe('warmup detection', () => { + it('detects real warmup subagents from the first authored user message', () => { + expect( + isWarmupSubagent([ + msg({ + type: 'user', + content: 'Warmup', + }), + ]) + ).toBe(true); + }); + + it('ignores synthetic replay rows when detecting warmup subagents', () => { + expect( + isWarmupSubagent([ + msg({ + type: 'user', + content: 'Warmup', + isMeta: true, + isReplay: true, + isSynthetic: true, + }), + msg({ + type: 'user', + content: 'Real subagent prompt', + }), + ]) + ).toBe(false); + }); + }); describe('Phase 1: agent-id matching', () => { it('sets linkType to agent-id when agentId matches subagent id', () => { @@ -156,6 +197,7 @@ describe('SubagentResolver.linkType', () => { messages: [ msg({ type: 'user', + protocolKind: 'teammate-message', content: `Hello`, }), ], @@ -210,6 +252,102 @@ describe('SubagentResolver.linkType', () => { expect(sub.linkType).toBe('team-member-id'); expect(sub.parentTaskId).toBe(taskCallId); }); + + it('ignores synthetic replay rows before teammate_id matching', () => { + const taskCallId = 'task-call-synthetic-prefix'; + const memberName = 'reviewer'; + + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: taskCallId, + name: 'Task', + input: { prompt: 'review', team_name: 'team-x', name: memberName }, + }, + ], + toolCalls: [ + { + id: taskCallId, + name: 'Task', + input: { prompt: 'review', team_name: 'team-x', name: memberName }, + isTask: true, + taskDescription: 'review', + taskSubagentType: 'general-purpose', + }, + ], + }), + ]; + + const sub = subagent({ + id: 'team-file-with-synthetic-replay', + messages: [ + msg({ + type: 'user', + content: 'Human: I tested the feature looks good', + isMeta: true, + isReplay: true, + isSynthetic: true, + }), + msg({ + type: 'user', + content: `Human: Review this`, + }), + ], + }); + + linkToTaskCalls([sub], extractTaskCalls(messages), messages); + + expect(sub.linkType).toBe('team-member-id'); + expect(sub.parentTaskId).toBe(taskCallId); + }); + + it('still matches ordinary human replay rows by teammate_id', () => { + const taskCallId = 'task-call-human-replay'; + const memberName = 'qa'; + + const messages: ParsedMessage[] = [ + msg({ + type: 'assistant', + content: [ + { + type: 'tool_use', + id: taskCallId, + name: 'Task', + input: { prompt: 'test', team_name: 'team-x', name: memberName }, + }, + ], + toolCalls: [ + { + id: taskCallId, + name: 'Task', + input: { prompt: 'test', team_name: 'team-x', name: memberName }, + isTask: true, + taskDescription: 'test', + taskSubagentType: 'general-purpose', + }, + ], + }), + ]; + + const sub = subagent({ + id: 'team-file-human-replay', + messages: [ + msg({ + type: 'user', + isReplay: true, + content: `Test this`, + }), + ], + }); + + linkToTaskCalls([sub], extractTaskCalls(messages), messages); + + expect(sub.linkType).toBe('team-member-id'); + expect(sub.parentTaskId).toBe(taskCallId); + }); }); describe('unlinked subagents', () => { @@ -314,7 +452,7 @@ describe('SubagentResolver.linkType', () => { it('propagates parent-chain linkType from ancestor', () => { // Access private method const propagate = ( - resolver as unknown as { propagateTeamMetadata: Function } + resolver as unknown as { propagateTeamMetadata: PropagateTeamMetadata } ).propagateTeamMetadata.bind(resolver); const parentId = 'parent-last-uuid'; diff --git a/test/main/services/parsing/MessageClassifier.test.ts b/test/main/services/parsing/MessageClassifier.test.ts index fe071de6..0f80755c 100644 --- a/test/main/services/parsing/MessageClassifier.test.ts +++ b/test/main/services/parsing/MessageClassifier.test.ts @@ -12,6 +12,7 @@ import { describe, expect, it } from 'vitest'; import { classifyMessages } from '../../../../src/main/services/parsing/MessageClassifier'; + import type { ParsedMessage } from '../../../../src/main/types'; // ============================================================================= @@ -135,6 +136,18 @@ describe('MessageClassifier', () => { const [result] = classifyMessages([message]); expect(result.category).toBe('system'); }); + + it('should classify array content with stderr as system', () => { + const message = createMessage({ + type: 'user', + content: [ + { type: 'text', text: 'Command failed' }, + ], + isMeta: false, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('system'); + }); }); describe('compact category', () => { @@ -196,6 +209,85 @@ describe('MessageClassifier', () => { expect(result.category).toBe('hardNoise'); }); + it('should classify synthetic user text replay as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: 'Human: I tested the feature looks good', + isMeta: true, + isReplay: true, + isSynthetic: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should not classify synthetic teammate-message replay as a user chunk', () => { + const message = createMessage({ + type: 'user', + content: + 'Human: I tested it', + isReplay: true, + isSynthetic: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should not classify structured teammate protocol as a user chunk', () => { + const message = createMessage({ + type: 'user', + content: 'plain protocol payload', + protocolKind: 'teammate-message', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + + it('should classify structured coordinator user-role text as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: 'Human: I tested the feature looks good', + origin: { kind: 'coordinator' }, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should classify synthetic structured teammate protocol as hardNoise', () => { + const message = createMessage({ + type: 'user', + content: + 'Human: I tested it', + protocolKind: 'teammate-message', + isSynthetic: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('hardNoise'); + }); + + it('should keep synthetic user tool results in the AI response flow', () => { + const message = createMessage({ + type: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'result text' }], + isMeta: true, + isSynthetic: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + + it('should keep non-replay synthetic meta text in the AI response flow', () => { + const message = createMessage({ + type: 'user', + content: 'Base directory for this skill: /tmp/skill', + isMeta: true, + isSynthetic: true, + sourceToolUseID: 'tool-1', + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('ai'); + }); + it('should classify empty stdout as hardNoise', () => { const message = createMessage({ type: 'user', @@ -205,6 +297,18 @@ describe('MessageClassifier', () => { expect(result.category).toBe('hardNoise'); }); + it('should keep synthetic replay command output as a system chunk', () => { + const message = createMessage({ + type: 'user', + content: 'Set model to sonnet', + isMeta: true, + isReplay: true, + isSynthetic: true, + }); + const [result] = classifyMessages([message]); + expect(result.category).toBe('system'); + }); + it('should classify file-history-snapshot as hardNoise', () => { const message = createMessage({ type: 'file-history-snapshot' as ParsedMessage['type'], diff --git a/test/main/services/parsing/SessionParser.test.ts b/test/main/services/parsing/SessionParser.test.ts index 7e48124b..948a011c 100644 --- a/test/main/services/parsing/SessionParser.test.ts +++ b/test/main/services/parsing/SessionParser.test.ts @@ -9,17 +9,17 @@ * - Time range calculation */ -import { describe, expect, it, vi, beforeEach } from 'vitest'; +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; +import { + type ParsedSession, + SessionParser, +} from '@main/services/parsing/SessionParser'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { - SessionParser, - type ParsedSession, -} from '../../../../src/main/services/parsing/SessionParser'; -import type { ParsedMessage } from '../../../../src/main/types'; -import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider'; +import type { ParsedMessage } from '@main/types'; // ============================================================================= // Mock ProjectScanner @@ -361,6 +361,48 @@ describe('SessionParser', () => { expect(responses[0].uuid).toBe('asst-1'); }); + it('should not stop at structured non-human user-role messages', () => { + const userMsgUuid = 'user-1'; + const messages = [ + createMessage({ uuid: userMsgUuid, type: 'user', content: 'Q1' }), + createMessage({ + uuid: 'protocol-1', + type: 'user', + content: 'Looks good', + protocolKind: 'teammate-message', + origin: { kind: 'teammate' }, + isSynthetic: true, + }), + createMessage({ + uuid: 'asst-1', + type: 'assistant', + content: [{ type: 'text', text: 'A1' }], + }), + createMessage({ + uuid: 'coordinator-1', + type: 'user', + content: 'queued coordination update', + origin: { kind: 'coordinator' }, + isMeta: true, + isSynthetic: true, + }), + createMessage({ + uuid: 'asst-2', + type: 'assistant', + content: [{ type: 'text', text: 'A2' }], + }), + createMessage({ uuid: 'user-2', type: 'user', content: 'Q2' }), + createMessage({ + uuid: 'asst-3', + type: 'assistant', + content: [{ type: 'text', text: 'A3' }], + }), + ]; + + const responses = parser.getResponses(messages, userMsgUuid); + expect(responses.map((message) => message.uuid)).toEqual(['asst-1', 'asst-2']); + }); + it('should return empty for non-existent message', () => { const messages = [createMessage({ uuid: 'user-1', type: 'user', content: 'Q' })]; diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index e40a5fe5..a7f157e0 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -1,12 +1,10 @@ +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'; -import * as fs from 'fs/promises'; - -import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; -import { TeamMemberLogsFinder } from '../../../../src/main/services/team/TeamMemberLogsFinder'; - describe('TeamMemberLogsFinder', () => { let tmpDir: string | null = null; @@ -964,6 +962,7 @@ describe('TeamMemberLogsFinder', () => { JSON.stringify({ timestamp: '2026-01-01T00:00:01.000Z', type: 'user', + protocolKind: 'teammate-message', message: { role: 'user', content: @@ -990,6 +989,144 @@ describe('TeamMemberLogsFinder', () => { } }); + 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: + 'Please implement it', + }, + }), + ].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: + 'Please implement it', + }, + }), + ].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); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index b670e1bb..4c60b7ee 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -1,18 +1,18 @@ +import { + analyzeSessionFileMetadata, + calculateMetrics, + countJsonlFileWithStats, + extractFirstUserMessagePreview, + parseJsonlFile, + parseJsonlFileWithStats, + parseJsonlLine, +} from '@main/utils/jsonl'; import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; import { describe, expect, it } from 'vitest'; -import { - analyzeSessionFileMetadata, - calculateMetrics, - countJsonlFileWithStats, - parseJsonlFile, - parseJsonlFileWithStats, - parseJsonlLine, -} from '../../../src/main/utils/jsonl'; - -import type { ParsedMessage } from '../../../src/main/types'; +import type { ParsedMessage } from '@main/types'; // Helper to create a minimal ParsedMessage function createMessage(overrides: Partial = {}): ParsedMessage { @@ -145,6 +145,52 @@ describe('jsonl', () => { }); describe('analyzeSessionFileMetadata', () => { + it('ignores structured non-human rows in head-only first user previews', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-head-preview-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + uuid: 'coordinator-1', + timestamp: '2026-01-01T00:00:00.000Z', + origin: { kind: 'coordinator' }, + isSynthetic: true, + message: { + role: 'user', + content: 'Human: I tested the feature looks good', + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'protocol-1', + timestamp: '2026-01-01T00:00:01.000Z', + protocolKind: 'teammate-message', + isSynthetic: true, + message: { + role: 'user', + content: 'Looks good', + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:02.000Z', + message: { role: 'user', content: 'real user request' }, + isMeta: false, + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const preview = await extractFirstUserMessagePreview(filePath); + + expect(preview?.text).toBe('real user request'); + expect(preview?.timestamp).toBe('2026-01-01T00:00:02.000Z'); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); + it('should extract first message, count, ongoing state, and git branch in one pass', async () => { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-')); try { @@ -190,6 +236,44 @@ describe('jsonl', () => { } } }); + + it('ignores synthetic user replays when selecting first user metadata', async () => { + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-synthetic-')); + try { + const filePath = path.join(tempDir, 'session.jsonl'); + const lines = [ + JSON.stringify({ + type: 'user', + uuid: 'synthetic-replay-1', + timestamp: '2026-01-01T00:00:00.000Z', + gitBranch: 'feature/test', + isReplay: true, + isSynthetic: true, + message: { + role: 'user', + content: 'Human: I tested the feature looks good', + }, + }), + JSON.stringify({ + type: 'user', + uuid: 'u1', + timestamp: '2026-01-01T00:00:01.000Z', + gitBranch: 'feature/test', + message: { role: 'user', content: 'real user request' }, + isMeta: false, + }), + ]; + fs.writeFileSync(filePath, `${lines.join('\n')}\n`, 'utf8'); + + const result = await analyzeSessionFileMetadata(filePath); + + expect(result.firstUserMessage?.text).toBe('real user request'); + expect(result.firstUserMessage?.timestamp).toBe('2026-01-01T00:00:01.000Z'); + expect(result.messageCount).toBe(1); + } finally { + fs.rmSync(tempDir, { recursive: true, force: true }); + } + }); }); describe('tolerant parsing', () => { @@ -379,6 +463,88 @@ describe('jsonl', () => { expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); }); + it('treats synthetic user-role replays as internal metadata', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'synthetic-user-replay-1', + timestamp: '2026-04-12T15:36:14.250Z', + isReplay: true, + isSynthetic: true, + message: { + role: 'user', + content: 'Human: I tested the feature looks good', + }, + }) + ); + + expect(parsed?.isMeta).toBe(true); + expect(parsed?.isReplay).toBe(true); + expect(parsed?.isSynthetic).toBe(true); + }); + + it('preserves structured user-role provenance fields', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'protocol-user-replay-1', + timestamp: '2026-04-12T15:36:14.250Z', + isReplay: true, + isSynthetic: true, + origin: { kind: 'coordinator' }, + protocolKind: 'teammate-message', + message: { + role: 'user', + content: 'plain protocol payload', + }, + }) + ); + + expect(parsed?.origin).toEqual({ kind: 'coordinator' }); + expect(parsed?.protocolKind).toBe('teammate-message'); + expect(parsed?.isMeta).toBe(true); + }); + + it('keeps replayed human user-role messages visible', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'human-user-replay-1', + timestamp: '2026-04-12T15:36:14.250Z', + isReplay: true, + message: { + role: 'user', + content: 'Human: I tested the feature looks good', + }, + }) + ); + + expect(parsed?.isMeta).toBe(false); + expect(parsed?.isReplay).toBe(true); + expect(parsed?.isSynthetic).toBeUndefined(); + }); + it('parses codex-native projected assistant rows with usage intact', () => { const parsed = parseJsonlLine( JSON.stringify({ diff --git a/test/renderer/utils/displayItemBuilder.test.ts b/test/renderer/utils/displayItemBuilder.test.ts index 08bed85b..81f3104d 100644 --- a/test/renderer/utils/displayItemBuilder.test.ts +++ b/test/renderer/utils/displayItemBuilder.test.ts @@ -1,11 +1,12 @@ -import { describe, expect, it } from 'vitest'; import { buildDisplayItems, buildDisplayItemsFromMessages, -} from '../../../src/renderer/utils/displayItemBuilder'; -import type { ParsedMessage } from '../../../src/main/types/messages'; -import type { SemanticStep } from '../../../src/main/types/chunks'; -import type { AIGroupLastOutput } from '../../../src/renderer/types/groups'; +} from '@renderer/utils/displayItemBuilder'; +import { describe, expect, it } from 'vitest'; + +import type { SemanticStep } from '@main/types/chunks'; +import type { ParsedMessage } from '@main/types/messages'; +import type { AIGroupLastOutput } from '@renderer/types/groups'; /** * Helper to create a minimal ParsedMessage for testing. @@ -100,6 +101,86 @@ describe('buildDisplayItemsFromMessages', () => { if (inputItems[0].type !== 'subagent_input') throw new Error('Expected subagent_input'); expect(inputItems[0].content).toBe('Please run the tests'); }); + + it('does not render synthetic replay text as subagent input', () => { + const userMsg = makeMessage({ + uuid: 'synthetic-replay-1', + type: 'user', + isMeta: false, + isReplay: true, + isSynthetic: true, + content: 'Human: I tested the feature looks good', + toolResults: [], + timestamp: new Date('2025-01-01T00:00:00Z'), + }); + + const items = buildDisplayItemsFromMessages([userMsg], []); + + expect(items).toEqual([]); + }); + + it('does not render synthetic teammate-message replay as a teammate message', () => { + const userMsg = makeMessage({ + uuid: 'synthetic-teammate-replay-1', + type: 'user', + isMeta: false, + isReplay: true, + isSynthetic: true, + content: + 'Human: I tested it', + toolResults: [], + timestamp: new Date('2025-01-01T00:00:00Z'), + }); + + const items = buildDisplayItemsFromMessages([userMsg], []); + + expect(items.some((item) => item.type === 'teammate_message')).toBe(false); + expect(items.some((item) => item.type === 'subagent_input')).toBe(false); + }); + + it('renders structured non-synthetic teammate protocol messages', () => { + const userMsg = makeMessage({ + uuid: 'structured-teammate-message-1', + type: 'user', + isMeta: false, + protocolKind: 'teammate-message', + content: + 'Looks good', + toolResults: [], + timestamp: new Date('2025-01-01T00:00:00Z'), + }); + + const items = buildDisplayItemsFromMessages([userMsg], []); + + expect(items.some((item) => item.type === 'teammate_message')).toBe(true); + }); + + it('does not route assistant text through subagent input', () => { + const assistantMsg = makeMessage({ + uuid: 'assistant-text-1', + type: 'assistant', + content: [ + { + type: 'text', + text: 'Assistant output', + }, + ], + toolResults: [], + timestamp: new Date('2025-01-01T00:00:00Z'), + }); + + const items = buildDisplayItemsFromMessages([assistantMsg], []); + + expect(items.some((item) => item.type === 'subagent_input')).toBe(false); + expect(items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'output', + content: 'Assistant output', + }), + ]) + ); + }); }); }); diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts index 06064df4..2789b11b 100644 --- a/test/renderer/utils/sessionAnalyzer.test.ts +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -1,7 +1,7 @@ -import { describe, it, expect } from 'vitest'; - import { analyzeSession } from '@renderer/utils/sessionAnalyzer'; -import type { ParsedMessage, Session, SessionDetail, SessionMetrics, Process } from '@shared/types'; +import { beforeEach, describe, expect, it } from 'vitest'; + +import type { ParsedMessage, Process, Session, SessionDetail, SessionMetrics } from '@shared/types'; // ============================================================================= // Test Helpers @@ -419,6 +419,37 @@ describe('analyzeSession', () => { const report = analyzeSession(createMockDetail({ messages })); expect(report.frictionSignals.correctionCount).toBe(0); }); + + it('does not count synthetic user replay text as friction', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + isReplay: true, + isSynthetic: true, + content: 'No, wrong, actually this is synthetic replay', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.frictionSignals.correctionCount).toBe(0); + expect(report.frictionSignals.frictionRate).toBe(0); + }); + + it('does not count structured protocol rows as friction', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + protocolKind: 'teammate-message', + content: 'No, wrong, actually this is protocol', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.frictionSignals.correctionCount).toBe(0); + expect(report.frictionSignals.frictionRate).toBe(0); + }); }); // ------------------------------------------------------------------------- @@ -562,6 +593,49 @@ describe('analyzeSession', () => { expect(report.idleAnalysis.idleGapCount).toBe(0); expect(report.idleAnalysis.totalIdleSeconds).toBe(0); }); + + it('does not treat structured protocol rows as user idle endpoints', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'assistant', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + isMeta: false, + protocolKind: 'teammate-message', + content: + 'Looks good', + timestamp: new Date('2024-01-01T10:02:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.idleAnalysis.idleGapCount).toBe(0); + expect(report.idleAnalysis.totalIdleSeconds).toBe(0); + }); + }); + + describe('key events', () => { + it('does not label structured protocol rows as user key events', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + protocolKind: 'teammate-message', + content: 'start feature handoff note', + timestamp: new Date('2024-01-01T10:00:00Z'), + }), + createMockMessage({ + type: 'user', + content: 'start feature implementation', + timestamp: new Date('2024-01-01T10:01:00Z'), + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.keyEvents).toHaveLength(1); + expect(report.keyEvents[0]?.label).toBe('User: start feature implementation'); + }); }); // ------------------------------------------------------------------------- @@ -815,6 +889,45 @@ describe('analyzeSession', () => { expect(report.promptQuality.assessment).toBe('underspecified'); expect(report.promptQuality.firstMessageLengthChars).toBe('Fix the bug'.length); }); + + it('ignores synthetic user replay text for first prompt length', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + isReplay: true, + isSynthetic: true, + content: 'Human: I tested the feature looks good', + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build the real feature', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.firstMessageLengthChars).toBe('Build the real feature'.length); + }); + + it('ignores structured protocol rows for first prompt length', () => { + const messages: ParsedMessage[] = [ + createMockMessage({ + type: 'user', + isMeta: false, + protocolKind: 'teammate-message', + content: 'plain protocol payload', + }), + createMockMessage({ + type: 'user', + isMeta: false, + content: 'Build the real feature', + }), + ]; + + const report = analyzeSession(createMockDetail({ messages })); + expect(report.promptQuality.firstMessageLengthChars).toBe('Build the real feature'.length); + }); }); describe('subagent metrics from processes', () => { diff --git a/test/shared/utils/userTurnProvenance.test.ts b/test/shared/utils/userTurnProvenance.test.ts new file mode 100644 index 00000000..58078a9f --- /dev/null +++ b/test/shared/utils/userTurnProvenance.test.ts @@ -0,0 +1,113 @@ +import { + classifyUserTurnProvenance, + isDisplayableTeammateProtocol, + isHumanAuthoredUserTurn, + isSyntheticReplayNoise, +} from '@shared/utils/userTurnProvenance'; +import { describe, expect, it } from 'vitest'; + +describe('userTurnProvenance', () => { + it('keeps replay-only user text human-authored', () => { + const message = { + type: 'user', + isReplay: true, + content: 'Human: I tested the feature looks good', + }; + + expect(classifyUserTurnProvenance(message)).toBe('human'); + expect(isHumanAuthoredUserTurn(message)).toBe(true); + }); + + it('treats an origin object without kind like absent origin', () => { + const message = { + type: 'user', + origin: {}, + content: 'ordinary user text', + }; + + expect(classifyUserTurnProvenance(message)).toBe('human'); + expect(isHumanAuthoredUserTurn(message)).toBe(true); + }); + + it('uses structured provenance before legacy text shape', () => { + const message = { + type: 'user', + protocolKind: 'teammate-message', + content: 'Plain protocol payload', + }; + + expect(classifyUserTurnProvenance(message)).toBe('teammate-protocol'); + expect(isHumanAuthoredUserTurn(message)).toBe(false); + expect(isDisplayableTeammateProtocol(message)).toBe(true); + }); + + it('keeps legacy teammate protocol detection as fallback', () => { + const message = { + type: 'user', + content: + 'Human: Looks good', + }; + + expect(classifyUserTurnProvenance(message)).toBe('teammate-protocol'); + expect(isDisplayableTeammateProtocol(message)).toBe(true); + }); + + it('hides synthetic replay text without hiding synthetic tool results or command output', () => { + expect( + isSyntheticReplayNoise({ + type: 'user', + isReplay: true, + isSynthetic: true, + content: 'Human: I tested the feature looks good', + }) + ).toBe(true); + + expect( + isSyntheticReplayNoise({ + type: 'user', + isReplay: true, + isSynthetic: true, + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'result', + }, + ], + }) + ).toBe(false); + + expect( + isSyntheticReplayNoise({ + type: 'user', + isReplay: true, + isSynthetic: true, + sourceToolUseID: 'tool-1', + content: 'result', + }) + ).toBe(false); + + expect( + isSyntheticReplayNoise({ + type: 'user', + isReplay: true, + isSynthetic: true, + content: 'Set model to sonnet', + }) + ).toBe(false); + }); + + it('does not display synthetic teammate protocol as real teammate output', () => { + const message = { + type: 'user', + isReplay: true, + isSynthetic: true, + protocolKind: 'teammate-message', + content: + 'Looks good', + }; + + expect(classifyUserTurnProvenance(message)).toBe('teammate-protocol'); + expect(isDisplayableTeammateProtocol(message)).toBe(false); + }); +});