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([]);
|
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', () => {
|
it('extracts tool_use input and tool_result output without rendering huge payloads', () => {
|
||||||
const hugeOutput = 'x'.repeat(10_000);
|
const hugeOutput = 'x'.repeat(10_000);
|
||||||
const result = extractMemberLogPreviewItems({
|
const result = extractMemberLogPreviewItems({
|
||||||
|
|
|
||||||
|
|
@ -1,3 +1,5 @@
|
||||||
|
import { isHumanAuthoredUserTurn, type MessageOriginLike } from '@shared/utils/userTurnProvenance';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberLogPreviewItem,
|
MemberLogPreviewItem,
|
||||||
MemberLogPreviewItemKind,
|
MemberLogPreviewItemKind,
|
||||||
|
|
@ -20,6 +22,10 @@ export interface MemberLogPreviewParsedMessage {
|
||||||
timestamp: Date | string;
|
timestamp: Date | string;
|
||||||
content: string | MemberLogPreviewContentBlock[];
|
content: string | MemberLogPreviewContentBlock[];
|
||||||
isMeta?: boolean;
|
isMeta?: boolean;
|
||||||
|
isSynthetic?: boolean;
|
||||||
|
isReplay?: boolean;
|
||||||
|
origin?: MessageOriginLike;
|
||||||
|
protocolKind?: string;
|
||||||
toolCalls?: readonly {
|
toolCalls?: readonly {
|
||||||
id?: string;
|
id?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
|
|
@ -2065,13 +2071,6 @@ function resolveMessageRole(message: MemberLogPreviewParsedMessage): string {
|
||||||
return message.role ?? message.type ?? '';
|
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: {
|
function buildItemId(input: {
|
||||||
provider: MemberLogStreamProvider;
|
provider: MemberLogStreamProvider;
|
||||||
sourceId: string;
|
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);
|
const inboundPreview = extractInboundTextPreview(message.content, textLimit);
|
||||||
if (inboundPreview) {
|
if (inboundPreview) {
|
||||||
candidates.push(
|
candidates.push(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type EnhancedAIChunk,
|
type EnhancedAIChunk,
|
||||||
type EnhancedChunk,
|
type EnhancedChunk,
|
||||||
isEnhancedAIChunk,
|
isEnhancedAIChunk,
|
||||||
|
isHumanAuthoredParsedUserMessage,
|
||||||
type ParsedMessage,
|
type ParsedMessage,
|
||||||
type Process,
|
type Process,
|
||||||
type SemanticStepGroup,
|
type SemanticStepGroup,
|
||||||
|
|
@ -85,19 +86,7 @@ export async function buildSubagentDetail(
|
||||||
// Build chunks with semantic steps
|
// Build chunks with semantic steps
|
||||||
const chunks = buildChunksFn(parsedSession.messages, nestedSubagents);
|
const chunks = buildChunksFn(parsedSession.messages, nestedSubagents);
|
||||||
|
|
||||||
// Extract description (try to get from first user message)
|
const description = deriveSubagentDescription(parsedSession.messages);
|
||||||
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 += '...';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate timing
|
// Calculate timing
|
||||||
const times = parsedSession.messages.map((m) => m.timestamp.getTime());
|
const times = parsedSession.messages.map((m) => m.timestamp.getTime());
|
||||||
|
|
@ -144,3 +133,19 @@ export async function buildSubagentDetail(
|
||||||
return null;
|
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>')
|
* - synthetic assistant messages (model='<synthetic>')
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
HARD_NOISE_TAGS,
|
||||||
|
} from '@main/constants/messageTags';
|
||||||
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider';
|
||||||
import { type ChatHistoryEntry, type ContentBlock } from '@main/types';
|
import { type ChatHistoryEntry, type ContentBlock } from '@main/types';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import {
|
||||||
|
classifyUserTurnProvenance,
|
||||||
|
isDisplayableTeammateProtocol,
|
||||||
|
isSyntheticReplayNoise,
|
||||||
|
} from '@shared/utils/userTurnProvenance';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
import type { FileSystemProvider } from '@main/services/infrastructure/FileSystemProvider';
|
||||||
|
|
@ -40,11 +48,6 @@ function byteLen(chunk: string): number {
|
||||||
return Buffer.byteLength(chunk, 'utf8');
|
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.
|
* Hard noise entry types - these types are always filtered out.
|
||||||
*/
|
*/
|
||||||
|
|
@ -193,10 +196,30 @@ export class SessionContentFilter {
|
||||||
const userEntry = entry as {
|
const userEntry = entry as {
|
||||||
message?: { content?: string | ContentBlock[] };
|
message?: { content?: string | ContentBlock[] };
|
||||||
isMeta?: boolean;
|
isMeta?: boolean;
|
||||||
|
isSynthetic?: boolean;
|
||||||
|
isReplay?: boolean;
|
||||||
|
toolUseResult?: unknown;
|
||||||
|
sourceToolUseID?: unknown;
|
||||||
|
origin?: { kind?: string };
|
||||||
|
protocolKind?: string;
|
||||||
};
|
};
|
||||||
const content = userEntry.message?.content;
|
const content = userEntry.message?.content;
|
||||||
const isMeta = userEntry.isMeta;
|
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
|
// Internal user messages (tool results) - part of AI response flow
|
||||||
// These ARE displayable as they're part of AIChunks
|
// These ARE displayable as they're part of AIChunks
|
||||||
if (isMeta === true) {
|
if (isMeta === true) {
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,16 @@
|
||||||
* - Link subagents to parent Task tool calls
|
* - 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 { calculateMetrics, checkMessagesOngoing, parseJsonlFile } from '@main/utils/jsonl';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isDisplayableTeammateProtocol } from '@shared/utils/userTurnProvenance';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
||||||
import { type ProjectScanner } from './ProjectScanner';
|
import { type ProjectScanner } from './ProjectScanner';
|
||||||
|
|
@ -142,8 +149,9 @@ export class SubagentResolver {
|
||||||
* - isSidechain: true (all subagents have this)
|
* - isSidechain: true (all subagents have this)
|
||||||
*/
|
*/
|
||||||
private isWarmupSubagent(messages: ParsedMessage[]): boolean {
|
private isWarmupSubagent(messages: ParsedMessage[]): boolean {
|
||||||
// Find the first user message
|
// Find the first authored user message. Synthetic SDK replays can also be
|
||||||
const firstUserMessage = messages.find((m) => m.type === 'user');
|
// user-role rows, but they must not decide whether a subagent is warmup.
|
||||||
|
const firstUserMessage = messages.find((m) => this.isAuthoredUserMessage(m));
|
||||||
if (!firstUserMessage) {
|
if (!firstUserMessage) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -158,12 +166,40 @@ export class SubagentResolver {
|
||||||
* Used for deterministic matching of team member files to their spawning Task calls.
|
* Used for deterministic matching of team member files to their spawning Task calls.
|
||||||
*/
|
*/
|
||||||
private extractTeammateId(messages: ParsedMessage[]): string | undefined {
|
private extractTeammateId(messages: ParsedMessage[]): string | undefined {
|
||||||
const firstUserMessage = messages.find((m) => m.type === 'user');
|
for (const message of messages) {
|
||||||
if (!firstUserMessage) return undefined;
|
if (!this.isAuthoredUserMessage(message)) continue;
|
||||||
|
|
||||||
const text = typeof firstUserMessage.content === 'string' ? firstUserMessage.content : '';
|
const text = this.extractUserText(message);
|
||||||
const match = /<teammate-message\s[^>]*?\bteammate_id="([^"]+)"/.exec(text);
|
const normalized = this.stripTranscriptSpeakerPrefix(text);
|
||||||
return match?.[1];
|
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 {
|
import {
|
||||||
|
isHumanAuthoredParsedUserMessage,
|
||||||
isParsedInternalUserMessage,
|
isParsedInternalUserMessage,
|
||||||
isParsedRealUserMessage,
|
isParsedRealUserMessage,
|
||||||
type ParsedMessage,
|
type ParsedMessage,
|
||||||
|
|
@ -178,8 +179,9 @@ export class SessionParser {
|
||||||
for (let i = userMsgIndex + 1; i < messages.length; i++) {
|
for (let i = userMsgIndex + 1; i < messages.length; i++) {
|
||||||
const msg = messages[i];
|
const msg = messages[i];
|
||||||
|
|
||||||
// Stop at next user message
|
// Stop at the next human-authored user turn. Structured protocol/meta
|
||||||
if (msg.type === 'user') break;
|
// rows can use type='user' but must not split the assistant response.
|
||||||
|
if (isHumanAuthoredParsedUserMessage(msg)) break;
|
||||||
|
|
||||||
// Include assistant responses
|
// Include assistant responses
|
||||||
if (msg.type === 'assistant') {
|
if (msg.type === 'assistant') {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
|
import {
|
||||||
|
isDisplayableTeammateProtocol,
|
||||||
|
isHumanAuthoredUserTurn,
|
||||||
|
} from '@shared/utils/userTurnProvenance';
|
||||||
import { createReadStream } from 'fs';
|
import { createReadStream } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
|
@ -1831,16 +1835,17 @@ export class TeamMemberLogsFinder {
|
||||||
try {
|
try {
|
||||||
const msg = JSON.parse(line) as Record<string, unknown>;
|
const msg = JSON.parse(line) as Record<string, unknown>;
|
||||||
|
|
||||||
const role = this.extractRole(msg);
|
|
||||||
const textContent = this.extractTextContent(msg);
|
const textContent = this.extractTextContent(msg);
|
||||||
|
|
||||||
|
const isAuthoredUserText = this.isAuthoredUserTextEntry(msg);
|
||||||
|
|
||||||
// Skip warmup messages
|
// Skip warmup messages
|
||||||
if (role === 'user' && textContent?.trim() === 'Warmup') {
|
if (isAuthoredUserText && textContent?.trim() === 'Warmup') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Extract description from first user message + collect teammate_id signal
|
// Extract description from first user message + collect teammate_id signal
|
||||||
if (role === 'user' && textContent) {
|
if (isAuthoredUserText && textContent) {
|
||||||
if (textContent.trimStart().startsWith('<teammate-message')) {
|
if (textContent.trimStart().startsWith('<teammate-message')) {
|
||||||
const parsed = parseAllTeammateMessages(textContent);
|
const parsed = parseAllTeammateMessages(textContent);
|
||||||
if (!description) {
|
if (!description) {
|
||||||
|
|
@ -1862,7 +1867,9 @@ export class TeamMemberLogsFinder {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Collect text_mention signal (lowest reliability — exact one member name in text)
|
// 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) {
|
if (textMention) {
|
||||||
signals.push({ member: textMention.name, source: 'text_mention' });
|
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 textContent = this.extractTextContent(entry);
|
||||||
|
const isAuthoredUserText = this.isAuthoredUserTextEntry(entry);
|
||||||
const lowerTextContent = textContent?.toLowerCase();
|
const lowerTextContent = textContent?.toLowerCase();
|
||||||
if (!teamMatched && lowerTextContent?.includes(normalizedTeam)) {
|
if (!teamMatched && isAuthoredUserText && lowerTextContent?.includes(normalizedTeam)) {
|
||||||
if (
|
if (
|
||||||
lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
|
lowerTextContent.includes(`on team "${normalizedTeam}"`) ||
|
||||||
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();
|
const normalizedText = textContent.trim();
|
||||||
if (
|
if (
|
||||||
normalizedText.length > 0 &&
|
normalizedText.length > 0 &&
|
||||||
|
|
@ -2052,7 +2059,7 @@ export class TeamMemberLogsFinder {
|
||||||
msg: Record<string, unknown>,
|
msg: Record<string, unknown>,
|
||||||
knownMembers: Set<string>
|
knownMembers: Set<string>
|
||||||
): { name: string; priority: number } | null {
|
): { name: string; priority: number } | null {
|
||||||
if (this.extractRole(msg) !== 'user') return null;
|
if (!this.isAuthoredUserTextEntry(msg)) return null;
|
||||||
|
|
||||||
const text = this.extractTextContent(msg);
|
const text = this.extractTextContent(msg);
|
||||||
if (!text) return null;
|
if (!text) return null;
|
||||||
|
|
@ -2073,6 +2080,11 @@ export class TeamMemberLogsFinder {
|
||||||
return null;
|
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 {
|
private extractTextContent(msg: Record<string, unknown>): string | null {
|
||||||
if (typeof msg.content === 'string') {
|
if (typeof msg.content === 'string') {
|
||||||
return msg.content;
|
return msg.content;
|
||||||
|
|
|
||||||
|
|
@ -162,6 +162,10 @@ interface ConversationalEntry extends BaseEntry {
|
||||||
*/
|
*/
|
||||||
export type ToolUseResultData = Record<string, unknown>;
|
export type ToolUseResultData = Record<string, unknown>;
|
||||||
|
|
||||||
|
export interface MessageOrigin {
|
||||||
|
kind?: string;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* CRITICAL: User entries serve two purposes:
|
* CRITICAL: User entries serve two purposes:
|
||||||
*
|
*
|
||||||
|
|
@ -182,6 +186,10 @@ export interface UserEntry extends ConversationalEntry {
|
||||||
type: 'user';
|
type: 'user';
|
||||||
message: UserMessage;
|
message: UserMessage;
|
||||||
isMeta?: boolean;
|
isMeta?: boolean;
|
||||||
|
isSynthetic?: boolean;
|
||||||
|
isReplay?: boolean;
|
||||||
|
origin?: MessageOrigin;
|
||||||
|
protocolKind?: string;
|
||||||
agentId?: string;
|
agentId?: string;
|
||||||
|
|
||||||
toolUseResult?: ToolUseResultData;
|
toolUseResult?: ToolUseResultData;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,14 @@
|
||||||
* parsed messages into categories for chunk building.
|
* parsed messages into categories for chunk building.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
classifyUserTurnProvenance,
|
||||||
|
isDisplayableTeammateProtocol,
|
||||||
|
isHumanAuthoredUserTurn,
|
||||||
|
isSyntheticReplayNoise,
|
||||||
|
type MessageOriginLike,
|
||||||
|
} from '@shared/utils/userTurnProvenance';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
EMPTY_STDERR,
|
EMPTY_STDERR,
|
||||||
EMPTY_STDOUT,
|
EMPTY_STDOUT,
|
||||||
|
|
@ -92,6 +100,14 @@ export interface ParsedMessage {
|
||||||
isSidechain: boolean;
|
isSidechain: boolean;
|
||||||
/** Whether this is a meta message */
|
/** Whether this is a meta message */
|
||||||
isMeta: boolean;
|
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) */
|
/** User type ("external" for user input) */
|
||||||
userType?: string;
|
userType?: string;
|
||||||
// Extracted tool information
|
// Extracted tool information
|
||||||
|
|
@ -140,8 +156,7 @@ export interface ParsedMessage {
|
||||||
* be treated as system responses, not user input that starts new chunks.
|
* be treated as system responses, not user input that starts new chunks.
|
||||||
*/
|
*/
|
||||||
export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
|
export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
|
||||||
if (msg.type !== 'user') return false;
|
if (!isHumanAuthoredParsedUserMessage(msg)) return false;
|
||||||
if (msg.isMeta) return false;
|
|
||||||
|
|
||||||
const content = msg.content;
|
const content = msg.content;
|
||||||
|
|
||||||
|
|
@ -180,9 +195,7 @@ export function isParsedRealUserMessage(msg: ParsedMessage): boolean {
|
||||||
* - "<system-reminder>...</system-reminder>" -> Hard noise
|
* - "<system-reminder>...</system-reminder>" -> Hard noise
|
||||||
*/
|
*/
|
||||||
export function isParsedUserChunkMessage(msg: ParsedMessage): boolean {
|
export function isParsedUserChunkMessage(msg: ParsedMessage): boolean {
|
||||||
if (msg.type !== 'user') return false;
|
if (!isHumanAuthoredParsedUserMessage(msg)) return false;
|
||||||
if (msg.isMeta === true) return false;
|
|
||||||
if (isParsedTeammateMessage(msg)) return false;
|
|
||||||
|
|
||||||
const content = msg.content;
|
const content = msg.content;
|
||||||
|
|
||||||
|
|
@ -273,7 +286,10 @@ export function isParsedSystemChunkMessage(msg: ParsedMessage): boolean {
|
||||||
// Array content - check text blocks
|
// Array content - check text blocks
|
||||||
if (Array.isArray(content)) {
|
if (Array.isArray(content)) {
|
||||||
return content.some(
|
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') {
|
if (msg.type === 'user') {
|
||||||
const content = msg.content;
|
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') {
|
if (typeof content === 'string') {
|
||||||
// Check if content contains ONLY noise tags (trim whitespace)
|
// Check if content contains ONLY noise tags (trim whitespace)
|
||||||
const trimmedContent = content.trim();
|
const trimmedContent = content.trim();
|
||||||
|
|
@ -404,20 +438,6 @@ export function isParsedCompactMessage(msg: ParsedMessage): boolean {
|
||||||
return msg.isCompactSummary === true;
|
return msg.isCompactSummary === true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function isHumanAuthoredParsedUserMessage(msg: ParsedMessage): boolean {
|
||||||
* Detect teammate messages - messages from team member agents.
|
return msg.type === 'user' && isHumanAuthoredUserTurn(msg);
|
||||||
* 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;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -341,6 +341,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
let codexNativeExecutableVersion: string | null | undefined;
|
let codexNativeExecutableVersion: string | null | undefined;
|
||||||
let isSidechain = false;
|
let isSidechain = false;
|
||||||
let isMeta = 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 userType: string | undefined;
|
||||||
let sourceToolUseID: string | undefined;
|
let sourceToolUseID: string | undefined;
|
||||||
let sourceToolAssistantUUID: string | undefined;
|
let sourceToolAssistantUUID: string | undefined;
|
||||||
|
|
@ -364,7 +368,11 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
content = entry.message.content ?? '';
|
content = entry.message.content ?? '';
|
||||||
role = entry.message.role;
|
role = entry.message.role;
|
||||||
agentId = entry.agentId;
|
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;
|
sourceToolUseID = entry.sourceToolUseID;
|
||||||
sourceToolAssistantUUID = entry.sourceToolAssistantUUID;
|
sourceToolAssistantUUID = entry.sourceToolAssistantUUID;
|
||||||
toolUseResult = entry.toolUseResult;
|
toolUseResult = entry.toolUseResult;
|
||||||
|
|
@ -415,6 +423,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null {
|
||||||
agentName,
|
agentName,
|
||||||
isSidechain,
|
isSidechain,
|
||||||
isMeta,
|
isMeta,
|
||||||
|
isSynthetic,
|
||||||
|
isReplay,
|
||||||
|
origin,
|
||||||
|
protocolKind,
|
||||||
userType,
|
userType,
|
||||||
isCompactSummary,
|
isCompactSummary,
|
||||||
level,
|
level,
|
||||||
|
|
@ -741,8 +753,8 @@ export async function analyzeSessionFileMetadata(
|
||||||
model = parsed.model ?? model;
|
model = parsed.model ?? model;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!firstUserMessage && entry.type === 'user') {
|
if (!firstUserMessage && parsed.type === 'user' && isParsedUserChunkMessage(parsed)) {
|
||||||
const content = entry.message?.content;
|
const content = parsed.content;
|
||||||
if (typeof content === 'string') {
|
if (typeof content === 'string') {
|
||||||
if (isCommandOutputContent(content)) {
|
if (isCommandOutputContent(content)) {
|
||||||
// Skip
|
// Skip
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@
|
||||||
|
|
||||||
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
import { isCommandOutputContent, sanitizeDisplayContent } from '@shared/utils/contentSanitizer';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
|
import { isHumanAuthoredUserTurn } from '@shared/utils/userTurnProvenance';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
|
@ -283,6 +284,10 @@ export async function extractFirstUserMessagePreview(
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
function extractPreviewFromUserEntry(entry: UserEntry): MessagePreview | null {
|
||||||
|
if (!isHumanAuthoredUserTurn(entry)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
const timestamp = entry.timestamp ?? new Date().toISOString();
|
const timestamp = entry.timestamp ?? new Date().toISOString();
|
||||||
const message = entry.message;
|
const message = entry.message;
|
||||||
if (!message) {
|
if (!message) {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
import { parseAllTeammateMessages } from '@shared/utils/teammateMessageParser';
|
||||||
|
import {
|
||||||
|
isDisplayableTeammateProtocol,
|
||||||
|
isHumanAuthoredUserTurn,
|
||||||
|
} from '@shared/utils/userTurnProvenance';
|
||||||
|
|
||||||
import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers';
|
import { estimateTokens, formatToolInput, formatToolResult, toDate } from './aiGroupHelpers';
|
||||||
import { extractSlashes, type PrecedingSlashInfo } from './slashCommandExtractor';
|
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)
|
// Add teammate messages from responses (one user message may contain multiple blocks)
|
||||||
if (responses) {
|
if (responses) {
|
||||||
for (const msg of responses) {
|
for (const msg of responses) {
|
||||||
if (msg.type !== 'user' || msg.isMeta) continue;
|
if (!isDisplayableTeammateProtocol(msg)) continue;
|
||||||
const rawText =
|
const rawText =
|
||||||
typeof msg.content === 'string'
|
typeof msg.content === 'string'
|
||||||
? msg.content
|
? msg.content
|
||||||
|
|
@ -401,7 +405,7 @@ export function buildDisplayItemsFromMessages(
|
||||||
|
|
||||||
// Check for teammate messages (non-meta user messages with <teammate-message> content)
|
// Check for teammate messages (non-meta user messages with <teammate-message> content)
|
||||||
// One user message may contain multiple <teammate-message> blocks
|
// 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 =
|
const rawText =
|
||||||
typeof msg.content === 'string'
|
typeof msg.content === 'string'
|
||||||
? msg.content
|
? msg.content
|
||||||
|
|
@ -411,7 +415,9 @@ export function buildDisplayItemsFromMessages(
|
||||||
.map((b) => b.text)
|
.map((b) => b.text)
|
||||||
.join('')
|
.join('')
|
||||||
: '';
|
: '';
|
||||||
const parsedBlocks = parseAllTeammateMessages(rawText);
|
const parsedBlocks = isDisplayableTeammateProtocol(msg)
|
||||||
|
? parseAllTeammateMessages(rawText)
|
||||||
|
: [];
|
||||||
if (parsedBlocks.length > 0) {
|
if (parsedBlocks.length > 0) {
|
||||||
for (const parsed of parsedBlocks) {
|
for (const parsed of parsedBlocks) {
|
||||||
displayItems.push({
|
displayItems.push({
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
detectSwitchPattern,
|
detectSwitchPattern,
|
||||||
} from '@renderer/utils/reportAssessments';
|
} from '@renderer/utils/reportAssessments';
|
||||||
import { calculateMessageCost } from '@shared/utils/pricing';
|
import { calculateMessageCost } from '@shared/utils/pricing';
|
||||||
|
import { isHumanAuthoredUserTurn } from '@shared/utils/userTurnProvenance';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
AgentTreeNode,
|
AgentTreeNode,
|
||||||
|
|
@ -354,6 +355,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
for (let i = 0; i < messages.length; i++) {
|
for (let i = 0; i < messages.length; i++) {
|
||||||
const m = messages[i];
|
const m = messages[i];
|
||||||
const msgType = m.type ?? 'unknown';
|
const msgType = m.type ?? 'unknown';
|
||||||
|
const isHumanUserMessage = msgType === 'user' && isHumanAuthoredUserTurn(m);
|
||||||
typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1);
|
typeCounts.set(msgType, (typeCounts.get(msgType) ?? 0) + 1);
|
||||||
const msgUuid = m.uuid ?? '';
|
const msgUuid = m.uuid ?? '';
|
||||||
const msgParent = m.parentUuid ?? '';
|
const msgParent = m.parentUuid ?? '';
|
||||||
|
|
@ -473,7 +475,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
if (msgType === 'assistant' && msgTs) {
|
if (msgType === 'assistant' && msgTs) {
|
||||||
lastAssistantTs = msgTs;
|
lastAssistantTs = msgTs;
|
||||||
}
|
}
|
||||||
if (msgType === 'user' && msgTs && lastAssistantTs) {
|
if (isHumanUserMessage && msgTs && lastAssistantTs) {
|
||||||
const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000;
|
const gap = (msgTs.getTime() - lastAssistantTs.getTime()) / 1000;
|
||||||
if (gap > IDLE_THRESHOLD_SEC) {
|
if (gap > IDLE_THRESHOLD_SEC) {
|
||||||
idleGaps.push({
|
idleGaps.push({
|
||||||
|
|
@ -485,7 +487,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- First user message length (prompt quality) ---
|
// --- First user message length (prompt quality) ---
|
||||||
if (msgType === 'user' && !firstUserSeen && !m.isMeta) {
|
if (isHumanUserMessage && !firstUserSeen) {
|
||||||
const contentText = extractTextContent(m);
|
const contentText = extractTextContent(m);
|
||||||
if (contentText.trim()) {
|
if (contentText.trim()) {
|
||||||
firstUserMessageLength = contentText.length;
|
firstUserMessageLength = contentText.length;
|
||||||
|
|
@ -617,7 +619,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
let label: string | null = null;
|
let label: string | null = null;
|
||||||
if (msgType === 'user' && typeof m.content === 'string') {
|
if (msgType === 'user' && typeof m.content === 'string') {
|
||||||
const content = m.content;
|
const content = m.content;
|
||||||
if (content.includes('start feature')) {
|
if (isHumanUserMessage && content.includes('start feature')) {
|
||||||
label = `User: ${content.slice(0, 60)}`;
|
label = `User: ${content.slice(0, 60)}`;
|
||||||
} else if (content.includes('being continued')) {
|
} else if (content.includes('being continued')) {
|
||||||
label = 'Context compaction/continuation';
|
label = 'Context compaction/continuation';
|
||||||
|
|
@ -639,7 +641,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport {
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Friction signals (user messages) ---
|
// --- Friction signals (user messages) ---
|
||||||
if (msgType === 'user' && !m.isMeta) {
|
if (isHumanUserMessage) {
|
||||||
const contentText = extractTextContent(m);
|
const contentText = extractTextContent(m);
|
||||||
if (contentText.trim()) {
|
if (contentText.trim()) {
|
||||||
userMessageCount++;
|
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 { ChunkBuilder } from '../../../../src/main/services/analysis/ChunkBuilder';
|
||||||
import { isAIChunk, isCompactChunk, isSystemChunk, isUserChunk } from '../../../../src/main/types';
|
import { isAIChunk, isCompactChunk, isSystemChunk, isUserChunk } from '../../../../src/main/types';
|
||||||
|
|
||||||
import type { ParsedMessage, Process } from '../../../../src/main/types';
|
import type { ParsedMessage, Process } from '../../../../src/main/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -350,6 +351,19 @@ describe('ChunkBuilder', () => {
|
||||||
const chunks = builder.buildChunks(messages);
|
const chunks = builder.buildChunks(messages);
|
||||||
expect(chunks).toHaveLength(0);
|
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', () => {
|
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');
|
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)', () => {
|
it('extracts sessionTitle from first user message (truncated to 100 chars)', () => {
|
||||||
const longText = 'a'.repeat(200);
|
const longText = 'a'.repeat(200);
|
||||||
const messages = [
|
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
|
* - Different description but same teammate_id still matches
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { SubagentResolver } from '@main/services/discovery/SubagentResolver';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { SubagentResolver } from '../../../../src/main/services/discovery/SubagentResolver';
|
import type { ProjectScanner } from '@main/services/discovery/ProjectScanner';
|
||||||
|
import type { ParsedMessage, Process, ToolCall } from '@main/types';
|
||||||
import type { ParsedMessage, Process, ToolCall } from '../../../../src/main/types';
|
|
||||||
import type { ProjectScanner } from '../../../../src/main/services/discovery/ProjectScanner';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
@ -68,6 +67,14 @@ function extractTaskCalls(messages: ParsedMessage[]): ToolCall[] {
|
||||||
return calls;
|
return calls;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type LinkToTaskCalls = (
|
||||||
|
subagents: Process[],
|
||||||
|
taskCalls: ToolCall[],
|
||||||
|
messages: ParsedMessage[]
|
||||||
|
) => void;
|
||||||
|
type IsWarmupSubagent = (messages: ParsedMessage[]) => boolean;
|
||||||
|
type PropagateTeamMetadata = (subagents: Process[]) => void;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Tests
|
// Tests
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -77,8 +84,42 @@ describe('SubagentResolver.linkType', () => {
|
||||||
|
|
||||||
// Access private method via prototype for testing
|
// Access private method via prototype for testing
|
||||||
const linkToTaskCalls = (
|
const linkToTaskCalls = (
|
||||||
resolver as unknown as { linkToTaskCalls: Function }
|
resolver as unknown as { linkToTaskCalls: LinkToTaskCalls }
|
||||||
).linkToTaskCalls.bind(resolver);
|
).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', () => {
|
describe('Phase 1: agent-id matching', () => {
|
||||||
it('sets linkType to agent-id when agentId matches subagent id', () => {
|
it('sets linkType to agent-id when agentId matches subagent id', () => {
|
||||||
|
|
@ -156,6 +197,7 @@ describe('SubagentResolver.linkType', () => {
|
||||||
messages: [
|
messages: [
|
||||||
msg({
|
msg({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
|
protocolKind: 'teammate-message',
|
||||||
content: `<teammate-message teammate_id="${memberName}" color="#ff0000" summary="do research">Hello</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.linkType).toBe('team-member-id');
|
||||||
expect(sub.parentTaskId).toBe(taskCallId);
|
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', () => {
|
describe('unlinked subagents', () => {
|
||||||
|
|
@ -314,7 +452,7 @@ describe('SubagentResolver.linkType', () => {
|
||||||
it('propagates parent-chain linkType from ancestor', () => {
|
it('propagates parent-chain linkType from ancestor', () => {
|
||||||
// Access private method
|
// Access private method
|
||||||
const propagate = (
|
const propagate = (
|
||||||
resolver as unknown as { propagateTeamMetadata: Function }
|
resolver as unknown as { propagateTeamMetadata: PropagateTeamMetadata }
|
||||||
).propagateTeamMetadata.bind(resolver);
|
).propagateTeamMetadata.bind(resolver);
|
||||||
|
|
||||||
const parentId = 'parent-last-uuid';
|
const parentId = 'parent-last-uuid';
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import { classifyMessages } from '../../../../src/main/services/parsing/MessageClassifier';
|
import { classifyMessages } from '../../../../src/main/services/parsing/MessageClassifier';
|
||||||
|
|
||||||
import type { ParsedMessage } from '../../../../src/main/types';
|
import type { ParsedMessage } from '../../../../src/main/types';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
|
|
@ -135,6 +136,18 @@ describe('MessageClassifier', () => {
|
||||||
const [result] = classifyMessages([message]);
|
const [result] = classifyMessages([message]);
|
||||||
expect(result.category).toBe('system');
|
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', () => {
|
describe('compact category', () => {
|
||||||
|
|
@ -196,6 +209,85 @@ describe('MessageClassifier', () => {
|
||||||
expect(result.category).toBe('hardNoise');
|
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', () => {
|
it('should classify empty stdout as hardNoise', () => {
|
||||||
const message = createMessage({
|
const message = createMessage({
|
||||||
type: 'user',
|
type: 'user',
|
||||||
|
|
@ -205,6 +297,18 @@ describe('MessageClassifier', () => {
|
||||||
expect(result.category).toBe('hardNoise');
|
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', () => {
|
it('should classify file-history-snapshot as hardNoise', () => {
|
||||||
const message = createMessage({
|
const message = createMessage({
|
||||||
type: 'file-history-snapshot' as ParsedMessage['type'],
|
type: 'file-history-snapshot' as ParsedMessage['type'],
|
||||||
|
|
|
||||||
|
|
@ -9,17 +9,17 @@
|
||||||
* - Time range calculation
|
* - 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 fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import {
|
import type { ParsedMessage } from '@main/types';
|
||||||
SessionParser,
|
|
||||||
type ParsedSession,
|
|
||||||
} from '../../../../src/main/services/parsing/SessionParser';
|
|
||||||
import type { ParsedMessage } from '../../../../src/main/types';
|
|
||||||
import { LocalFileSystemProvider } from '../../../../src/main/services/infrastructure/LocalFileSystemProvider';
|
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Mock ProjectScanner
|
// Mock ProjectScanner
|
||||||
|
|
@ -361,6 +361,48 @@ describe('SessionParser', () => {
|
||||||
expect(responses[0].uuid).toBe('asst-1');
|
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', () => {
|
it('should return empty for non-existent message', () => {
|
||||||
const messages = [createMessage({ uuid: 'user-1', type: 'user', content: 'Q' })];
|
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 os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
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', () => {
|
describe('TeamMemberLogsFinder', () => {
|
||||||
let tmpDir: string | null = null;
|
let tmpDir: string | null = null;
|
||||||
|
|
||||||
|
|
@ -964,6 +962,7 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
timestamp: '2026-01-01T00:00:01.000Z',
|
timestamp: '2026-01-01T00:00:01.000Z',
|
||||||
type: 'user',
|
type: 'user',
|
||||||
|
protocolKind: 'teammate-message',
|
||||||
message: {
|
message: {
|
||||||
role: 'user',
|
role: 'user',
|
||||||
content:
|
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 () => {
|
it('routing.sender overrides teammate_id="team-lead" from spawn message', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-logs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
setClaudeBasePathOverride(tmpDir);
|
||||||
|
|
|
||||||
|
|
@ -1,18 +1,18 @@
|
||||||
|
import {
|
||||||
|
analyzeSessionFileMetadata,
|
||||||
|
calculateMetrics,
|
||||||
|
countJsonlFileWithStats,
|
||||||
|
extractFirstUserMessagePreview,
|
||||||
|
parseJsonlFile,
|
||||||
|
parseJsonlFileWithStats,
|
||||||
|
parseJsonlLine,
|
||||||
|
} from '@main/utils/jsonl';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
import * as os from 'os';
|
import * as os from 'os';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import type { ParsedMessage } from '@main/types';
|
||||||
analyzeSessionFileMetadata,
|
|
||||||
calculateMetrics,
|
|
||||||
countJsonlFileWithStats,
|
|
||||||
parseJsonlFile,
|
|
||||||
parseJsonlFileWithStats,
|
|
||||||
parseJsonlLine,
|
|
||||||
} from '../../../src/main/utils/jsonl';
|
|
||||||
|
|
||||||
import type { ParsedMessage } from '../../../src/main/types';
|
|
||||||
|
|
||||||
// Helper to create a minimal ParsedMessage
|
// Helper to create a minimal ParsedMessage
|
||||||
function createMessage(overrides: Partial<ParsedMessage> = {}): ParsedMessage {
|
function createMessage(overrides: Partial<ParsedMessage> = {}): ParsedMessage {
|
||||||
|
|
@ -145,6 +145,52 @@ describe('jsonl', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('analyzeSessionFileMetadata', () => {
|
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 () => {
|
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-'));
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'jsonl-meta-'));
|
||||||
try {
|
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', () => {
|
describe('tolerant parsing', () => {
|
||||||
|
|
@ -379,6 +463,88 @@ describe('jsonl', () => {
|
||||||
expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real');
|
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', () => {
|
it('parses codex-native projected assistant rows with usage intact', () => {
|
||||||
const parsed = parseJsonlLine(
|
const parsed = parseJsonlLine(
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,12 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
|
||||||
import {
|
import {
|
||||||
buildDisplayItems,
|
buildDisplayItems,
|
||||||
buildDisplayItemsFromMessages,
|
buildDisplayItemsFromMessages,
|
||||||
} from '../../../src/renderer/utils/displayItemBuilder';
|
} from '@renderer/utils/displayItemBuilder';
|
||||||
import type { ParsedMessage } from '../../../src/main/types/messages';
|
import { describe, expect, it } from 'vitest';
|
||||||
import type { SemanticStep } from '../../../src/main/types/chunks';
|
|
||||||
import type { AIGroupLastOutput } from '../../../src/renderer/types/groups';
|
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.
|
* 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');
|
if (inputItems[0].type !== 'subagent_input') throw new Error('Expected subagent_input');
|
||||||
expect(inputItems[0].content).toBe('Please run the tests');
|
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 { 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
|
// Test Helpers
|
||||||
|
|
@ -419,6 +419,37 @@ describe('analyzeSession', () => {
|
||||||
const report = analyzeSession(createMockDetail({ messages }));
|
const report = analyzeSession(createMockDetail({ messages }));
|
||||||
expect(report.frictionSignals.correctionCount).toBe(0);
|
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.idleGapCount).toBe(0);
|
||||||
expect(report.idleAnalysis.totalIdleSeconds).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.assessment).toBe('underspecified');
|
||||||
expect(report.promptQuality.firstMessageLengthChars).toBe('Fix the bug'.length);
|
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', () => {
|
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