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:
parent
ab6ab1fc4c
commit
3849c01955
26 changed files with 1758 additions and 112 deletions
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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++;
|
||||
|
|
|
|||
257
src/shared/utils/userTurnProvenance.ts
Normal file
257
src/shared/utils/userTurnProvenance.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
75
test/main/services/analysis/SubagentDetailBuilder.test.ts
Normal file
75
test/main/services/analysis/SubagentDetailBuilder.test.ts
Normal 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)}...`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 = [
|
||||
|
|
|
|||
217
test/main/services/discovery/SessionContentFilter.test.ts
Normal file
217
test/main/services/discovery/SessionContentFilter.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
|
|
@ -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' })];
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}),
|
||||
])
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
113
test/shared/utils/userTurnProvenance.test.ts
Normal file
113
test/shared/utils/userTurnProvenance.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue