fix(provenance): classify synthetic user turns

* fix(provenance): classify synthetic user turns

* fix(provenance): keep assistant display rendering intact

* fix(provenance): preserve source tool result rows
This commit is contained in:
Илия 2026-05-26 23:51:17 +03:00 committed by GitHub
parent ab6ab1fc4c
commit 3849c01955
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
26 changed files with 1758 additions and 112 deletions

View file

@ -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: '<teammate-message teammate_id="alice">Looks good</teammate-message>',
}),
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({

View file

@ -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(

View file

@ -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;
}

View file

@ -21,9 +21,17 @@
* - synthetic assistant messages (model='<synthetic>')
*/
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 = ['<local-command-caveat>', '<system-reminder>'];
/**
* 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) {

View file

@ -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 = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
return match?.[1];
const text = this.extractUserText(message);
const normalized = this.stripTranscriptSpeakerPrefix(text);
const match = /<teammate-message\s[^>]*?\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();
}
/**

View file

@ -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') {

View file

@ -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<string, unknown>;
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('<teammate-message')) {
const parsed = parseAllTeammateMessages(textContent);
if (!description) {
@ -1862,7 +1867,9 @@ export class TeamMemberLogsFinder {
}
// Collect text_mention signal (lowest reliability — exact one member name in text)
const textMention = this.detectMemberFromMessage(msg, knownMembers);
const textMention = isAuthoredUserText
? this.detectMemberFromMessage(msg, knownMembers)
: null;
if (textMention) {
signals.push({ member: textMention.name, source: 'text_mention' });
}
@ -1986,10 +1993,10 @@ export class TeamMemberLogsFinder {
}
}
const role = this.extractRole(entry);
const textContent = this.extractTextContent(entry);
const isAuthoredUserText = this.isAuthoredUserTextEntry(entry);
const lowerTextContent = textContent?.toLowerCase();
if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) {
if (!teamMatched && isAuthoredUserText && lowerTextContent?.includes(normalizedTeam)) {
if (
lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
lowerTextContent.includes(`on team '${normalizedTeam}'`) ||
@ -1999,7 +2006,7 @@ export class TeamMemberLogsFinder {
}
}
if (role === 'user' && textContent && !description) {
if (isAuthoredUserText && textContent && !description) {
const normalizedText = textContent.trim();
if (
normalizedText.length > 0 &&
@ -2052,7 +2059,7 @@ export class TeamMemberLogsFinder {
msg: Record<string, unknown>,
knownMembers: Set<string>
): { 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<string, unknown>): boolean {
if (this.extractRole(msg) !== 'user') return false;
return isHumanAuthoredUserTurn(msg) || isDisplayableTeammateProtocol(msg);
}
private extractTextContent(msg: Record<string, unknown>): string | null {
if (typeof msg.content === 'string') {
return msg.content;

View file

@ -162,6 +162,10 @@ interface ConversationalEntry extends BaseEntry {
*/
export type ToolUseResultData = Record<string, unknown>;
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;

View file

@ -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 {
* - "<system-reminder>...</system-reminder>" -> 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: <teammate-message teammate_id="name" ...>content</teammate-message>
*/
const TEAMMATE_MESSAGE_REGEX = /^<teammate-message\s+teammate_id="([^"]+)"/;
function isParsedTeammateMessage(msg: ParsedMessage): boolean {
if (msg.type !== 'user' || msg.isMeta) return false;
const content = msg.content;
if (typeof content === 'string') return TEAMMATE_MESSAGE_REGEX.test(content.trim());
if (Array.isArray(content)) {
return content.some(
(block) => block.type === 'text' && TEAMMATE_MESSAGE_REGEX.test(block.text.trim())
);
}
return false;
export function isHumanAuthoredParsedUserMessage(msg: ParsedMessage): boolean {
return msg.type === 'user' && isHumanAuthoredUserTurn(msg);
}

View file

@ -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

View file

@ -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) {

View file

@ -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 <teammate-message> content)
// One user message may contain multiple <teammate-message> 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({

View file

@ -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++;

View file

@ -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';
}

View file

@ -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', () => {

View file

@ -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>): 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)}...`
);
});
});

View file

@ -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 = [

View file

@ -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>): 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: '<task-notification>done</task-notification>',
},
})
)
).toBe(false);
});
it('keeps non-synthetic teammate protocol rows displayable for attribution', () => {
expect(
SessionContentFilter.isDisplayableEntry(
userEntry({
protocolKind: 'teammate-message',
message: {
role: 'user',
content:
'<teammate-message teammate_id="alice">Looks good</teammate-message>',
},
})
)
).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: '<local-command-stdout>Set model to sonnet</local-command-stdout>',
},
})
)
).toBe(true);
});
});
});

View file

@ -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: `<teammate-message teammate_id="${memberName}" color="#ff0000" summary="do research">Hello</teammate-message>`,
}),
],
@ -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: <teammate-message teammate_id="${memberName}" color="#00ff00">Review this</teammate-message>`,
}),
],
});
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: `<teammate-message teammate_id="${memberName}" color="#00ff00">Test this</teammate-message>`,
}),
],
});
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';

View file

@ -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: '<local-command-stderr>Command failed</local-command-stderr>' },
],
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:
'<teammate-message teammate_id="coder" color="blue" summary="fake">Human: I tested it</teammate-message>',
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:
'<teammate-message teammate_id="coder" color="blue" summary="fake">Human: I tested it</teammate-message>',
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: '<local-command-stdout>Set model to sonnet</local-command-stdout>',
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'],

View file

@ -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: '<teammate-message teammate_id="alice">Looks good</teammate-message>',
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' })];

View file

@ -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:
'<teammate-message teammate_id="bob" color="blue" summary="Build the real task">Please implement it</teammate-message>',
},
}),
].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:
'<teammate-message teammate_id="bob" color="blue" summary="Build the real task">Please implement it</teammate-message>',
},
}),
].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);

View file

@ -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> = {}): 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: '<teammate-message teammate_id="alice">Looks good</teammate-message>',
},
}),
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({

View file

@ -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:
'<teammate-message teammate_id="coder" color="blue" summary="fake">Human: I tested it</teammate-message>',
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:
'<teammate-message teammate_id="coder" color="blue" summary="done">Looks good</teammate-message>',
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',
}),
])
);
});
});
});

View file

@ -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:
'<teammate-message teammate_id="alice">Looks good</teammate-message>',
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', () => {

View file

@ -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: <teammate-message teammate_id="alice">Looks good</teammate-message>',
};
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: '<local-command-stdout>Set model to sonnet</local-command-stdout>',
})
).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:
'<teammate-message teammate_id="alice">Looks good</teammate-message>',
};
expect(classifyUserTurnProvenance(message)).toBe('teammate-protocol');
expect(isDisplayableTeammateProtocol(message)).toBe(false);
});
});