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);
+ });
+});