feat(task-logs): show codex native trace fallback
This commit is contained in:
parent
ca21ab206e
commit
90aa2942f9
22 changed files with 2991 additions and 94 deletions
|
|
@ -11,13 +11,17 @@ const TASK_ACTIVITY_INDEX_CACHE_TTL_MS = 1_000;
|
|||
|
||||
interface TaskActivityIndex {
|
||||
expiresAt: number;
|
||||
generation: number;
|
||||
tasksById: Map<string, TeamTask>;
|
||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||
}
|
||||
|
||||
export class BoardTaskActivityRecordSource {
|
||||
private readonly indexCache = new Map<string, TaskActivityIndex>();
|
||||
private readonly indexInFlight = new Map<string, Promise<TaskActivityIndex>>();
|
||||
private readonly indexInFlight = new Map<
|
||||
string,
|
||||
{ generation: number; promise: Promise<TaskActivityIndex> }
|
||||
>();
|
||||
|
||||
constructor(
|
||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||
|
|
@ -38,33 +42,48 @@ export class BoardTaskActivityRecordSource {
|
|||
}
|
||||
|
||||
private async getTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||
const generation = this.getTranscriptDiscoveryGeneration(teamName);
|
||||
const cached = this.indexCache.get(teamName);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
if (cached && cached.generation === generation && cached.expiresAt > Date.now()) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
const existingPromise = this.indexInFlight.get(teamName);
|
||||
if (existingPromise) {
|
||||
return await existingPromise;
|
||||
const existingInFlight = this.indexInFlight.get(teamName);
|
||||
if (existingInFlight && existingInFlight.generation === generation) {
|
||||
return await existingInFlight.promise;
|
||||
}
|
||||
|
||||
const promise = this.buildTaskActivityIndex(teamName)
|
||||
const promise = this.buildTaskActivityIndex(teamName, generation)
|
||||
.then((index) => {
|
||||
this.indexCache.set(teamName, index);
|
||||
if (this.getTranscriptDiscoveryGeneration(teamName) === generation) {
|
||||
this.indexCache.set(teamName, index);
|
||||
}
|
||||
return index;
|
||||
})
|
||||
.finally(() => {
|
||||
this.indexInFlight.delete(teamName);
|
||||
if (this.indexInFlight.get(teamName)?.promise === promise) {
|
||||
this.indexInFlight.delete(teamName);
|
||||
}
|
||||
});
|
||||
this.indexInFlight.set(teamName, promise);
|
||||
this.indexInFlight.set(teamName, { generation, promise });
|
||||
return await promise;
|
||||
}
|
||||
|
||||
private async buildTaskActivityIndex(teamName: string): Promise<TaskActivityIndex> {
|
||||
private getTranscriptDiscoveryGeneration(teamName: string): number {
|
||||
const locator = this.transcriptSourceLocator as {
|
||||
getGeneration?: (teamName: string) => number;
|
||||
};
|
||||
return locator.getGeneration?.(teamName) ?? 0;
|
||||
}
|
||||
|
||||
private async buildTaskActivityIndex(
|
||||
teamName: string,
|
||||
generation: number
|
||||
): Promise<TaskActivityIndex> {
|
||||
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName),
|
||||
this.taskReader.getDeletedTasks(teamName),
|
||||
this.transcriptSourceLocator.listTranscriptFiles(teamName),
|
||||
this.listTranscriptFiles(teamName),
|
||||
]);
|
||||
|
||||
const tasks = [...activeTasks, ...deletedTasks];
|
||||
|
|
@ -72,6 +91,7 @@ export class BoardTaskActivityRecordSource {
|
|||
if (tasks.length === 0 || transcriptFiles.length === 0) {
|
||||
return {
|
||||
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||
generation,
|
||||
tasksById,
|
||||
recordsByTaskId: new Map(),
|
||||
};
|
||||
|
|
@ -85,8 +105,21 @@ export class BoardTaskActivityRecordSource {
|
|||
});
|
||||
return {
|
||||
expiresAt: Date.now() + TASK_ACTIVITY_INDEX_CACHE_TTL_MS,
|
||||
generation,
|
||||
tasksById,
|
||||
recordsByTaskId,
|
||||
};
|
||||
}
|
||||
|
||||
private async listTranscriptFiles(teamName: string): Promise<string[]> {
|
||||
const locator = this.transcriptSourceLocator as {
|
||||
getContext?: (teamName: string) => Promise<{ transcriptFiles: string[] } | null>;
|
||||
listTranscriptFiles?: (teamName: string) => Promise<string[]>;
|
||||
};
|
||||
const context = await locator.getContext?.(teamName);
|
||||
if (context) {
|
||||
return context.transcriptFiles;
|
||||
}
|
||||
return (await locator.listTranscriptFiles?.(teamName)) ?? [];
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityReco
|
|||
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
|
||||
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
|
||||
import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService';
|
||||
import { HistoricalBoardMcpRawProbe } from '../stream/HistoricalBoardMcpRawProbe';
|
||||
import { TaskLogTranscriptCandidateSelector } from '../stream/TaskLogTranscriptCandidateSelector';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
|
@ -37,6 +39,13 @@ export interface BoardTaskLogDiagnosticsReport {
|
|||
transcript: {
|
||||
fileCount: number;
|
||||
files: string[];
|
||||
parsedFileCount: number;
|
||||
candidateSelection?: {
|
||||
mode: 'activity_records' | 'historical_raw_probe' | 'none';
|
||||
candidateFileCount: number;
|
||||
rawProbeScannedFileCount?: number;
|
||||
rawProbeHitCount?: number;
|
||||
};
|
||||
};
|
||||
explicitRecords: {
|
||||
total: number;
|
||||
|
|
@ -249,15 +258,96 @@ export class BoardTaskLogDiagnosticsService {
|
|||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
|
||||
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
|
||||
private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService()
|
||||
private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService(),
|
||||
private readonly transcriptCandidateSelector: TaskLogTranscriptCandidateSelector = new TaskLogTranscriptCandidateSelector(),
|
||||
private readonly historicalRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
||||
) {}
|
||||
|
||||
private async getTranscriptContext(teamName: string): Promise<{
|
||||
projectDir?: string;
|
||||
transcriptFiles: string[];
|
||||
}> {
|
||||
const locator = this.transcriptSourceLocator as {
|
||||
getContext?: (
|
||||
teamName: string
|
||||
) => Promise<{ projectDir: string; transcriptFiles: string[] } | null>;
|
||||
listTranscriptFiles?: (teamName: string) => Promise<string[]>;
|
||||
};
|
||||
const context = await locator.getContext?.(teamName);
|
||||
if (context) {
|
||||
return {
|
||||
projectDir: context.projectDir,
|
||||
transcriptFiles: context.transcriptFiles,
|
||||
};
|
||||
}
|
||||
return {
|
||||
transcriptFiles: (await locator.listTranscriptFiles?.(teamName)) ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
private async parseDiagnosticCandidateFiles(args: {
|
||||
task: TeamTask;
|
||||
records: BoardTaskActivityRecord[];
|
||||
projectDir?: string;
|
||||
transcriptFiles: string[];
|
||||
}): Promise<{
|
||||
parsedMessagesByFile: Map<string, ParsedMessage[]>;
|
||||
candidateSelection: NonNullable<
|
||||
BoardTaskLogDiagnosticsReport['transcript']['candidateSelection']
|
||||
>;
|
||||
}> {
|
||||
if (args.transcriptFiles.length === 0) {
|
||||
return {
|
||||
parsedMessagesByFile: new Map(),
|
||||
candidateSelection: {
|
||||
mode: 'none',
|
||||
candidateFileCount: 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (args.records.length > 0) {
|
||||
const selection = this.transcriptCandidateSelector.selectInferredNativeTranscriptFiles({
|
||||
records: args.records,
|
||||
transcriptFiles: args.transcriptFiles,
|
||||
projectDir: args.projectDir,
|
||||
});
|
||||
return {
|
||||
parsedMessagesByFile:
|
||||
selection.filePaths.length > 0
|
||||
? await this.strictParser.parseFiles(selection.filePaths)
|
||||
: new Map(),
|
||||
candidateSelection: {
|
||||
mode: 'activity_records',
|
||||
candidateFileCount: selection.filePaths.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const rawProbe = await this.historicalRawProbe.findCandidateFiles({
|
||||
task: args.task,
|
||||
transcriptFiles: args.transcriptFiles,
|
||||
});
|
||||
return {
|
||||
parsedMessagesByFile:
|
||||
rawProbe.filePaths.length > 0
|
||||
? await this.strictParser.parseFiles(rawProbe.filePaths)
|
||||
: new Map(),
|
||||
candidateSelection: {
|
||||
mode: 'historical_raw_probe',
|
||||
candidateFileCount: rawProbe.filePaths.length,
|
||||
rawProbeScannedFileCount: rawProbe.scannedFileCount,
|
||||
rawProbeHitCount: rawProbe.hitCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async diagnose(teamName: string, taskRef: string): Promise<BoardTaskLogDiagnosticsReport> {
|
||||
const normalizedRef = normalizeRequestedTaskRef(taskRef);
|
||||
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
|
||||
const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName),
|
||||
this.taskReader.getDeletedTasks(teamName),
|
||||
this.transcriptSourceLocator.listTranscriptFiles(teamName),
|
||||
this.getTranscriptContext(teamName),
|
||||
]);
|
||||
|
||||
const tasks = [...activeTasks, ...deletedTasks];
|
||||
|
|
@ -267,7 +357,12 @@ export class BoardTaskLogDiagnosticsService {
|
|||
}
|
||||
|
||||
const records = await this.recordSource.getTaskRecords(teamName, task.id);
|
||||
const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles);
|
||||
const { parsedMessagesByFile, candidateSelection } = await this.parseDiagnosticCandidateFiles({
|
||||
task,
|
||||
records,
|
||||
projectDir: transcriptContext.projectDir,
|
||||
transcriptFiles: transcriptContext.transcriptFiles,
|
||||
});
|
||||
const stream = await this.streamService.getTaskLogStream(teamName, task.id);
|
||||
|
||||
const toolNameByUseId = buildToolNameMap(parsedMessagesByFile);
|
||||
|
|
@ -331,7 +426,7 @@ export class BoardTaskLogDiagnosticsService {
|
|||
}
|
||||
|
||||
const diagnosis: string[] = [];
|
||||
if (transcriptFiles.length === 0) {
|
||||
if (transcriptContext.transcriptFiles.length === 0) {
|
||||
diagnosis.push('No transcript files were found for this team.');
|
||||
}
|
||||
if (records.length === 0) {
|
||||
|
|
@ -373,8 +468,10 @@ export class BoardTaskLogDiagnosticsService {
|
|||
workIntervals,
|
||||
},
|
||||
transcript: {
|
||||
fileCount: transcriptFiles.length,
|
||||
files: transcriptFiles,
|
||||
fileCount: transcriptContext.transcriptFiles.length,
|
||||
files: transcriptContext.transcriptFiles,
|
||||
parsedFileCount: parsedMessagesByFile.size,
|
||||
candidateSelection,
|
||||
},
|
||||
explicitRecords: {
|
||||
total: records.length,
|
||||
|
|
|
|||
|
|
@ -16,3 +16,7 @@ function readEnabledFlag(value: string | undefined, defaultValue: boolean): bool
|
|||
export function isBoardTaskExactLogsReadEnabled(): boolean {
|
||||
return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true);
|
||||
}
|
||||
|
||||
export function isCodexNativeTraceFallbackEnabled(): boolean {
|
||||
return readEnabledFlag(process.env.CLAUDE_TEAM_CODEX_NATIVE_TRACE_FALLBACK_ENABLED, true);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection';
|
|||
import { createLogger } from '@shared/utils/logger';
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||
import { TeamConfigReader } from '../../TeamConfigReader';
|
||||
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||
|
|
@ -17,6 +16,14 @@ import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates';
|
|||
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
|
||||
|
||||
import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
||||
import { CodexNativeTaskLogStreamSource } from './CodexNativeTaskLogStreamSource';
|
||||
import { buildCodexNativeToolSignature } from './CodexNativeTraceProjector';
|
||||
import { HistoricalBoardMcpRawProbe } from './HistoricalBoardMcpRawProbe';
|
||||
import { TaskLogTranscriptCandidateSelector } from './TaskLogTranscriptCandidateSelector';
|
||||
import {
|
||||
canonicalizeBoardTaskLogToolName,
|
||||
isBoardTaskLogMcpToolName,
|
||||
} from './boardTaskLogToolNames';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
|
||||
|
|
@ -71,27 +78,9 @@ const INFERRED_RECORD_RANGE_AFTER_MS = 60_000;
|
|||
const STREAM_LAYOUT_CACHE_TTL_MS = 1_000;
|
||||
const STREAM_LAYOUT_BUILD_WARN_MS = 3_000;
|
||||
const RUNTIME_FALLBACK_WARN_MS = 3_000;
|
||||
const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([
|
||||
'task_complete',
|
||||
'task_set_status',
|
||||
'task_start',
|
||||
'review_approve',
|
||||
'review_request_changes',
|
||||
'review_start',
|
||||
]);
|
||||
const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([
|
||||
'review_request',
|
||||
'task_add_comment',
|
||||
'task_attach_comment_file',
|
||||
'task_attach_file',
|
||||
'task_get',
|
||||
'task_get_comment',
|
||||
'task_link',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_unlink',
|
||||
]);
|
||||
const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']);
|
||||
const INFERRED_CANDIDATE_SELECTION_WARN_COUNT = 100;
|
||||
const HISTORICAL_RAW_PROBE_WARN_MS = 3_000;
|
||||
const HISTORICAL_RAW_PROBE_WARN_FILE_COUNT = 500;
|
||||
const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']);
|
||||
|
||||
function emptyResponse(): BoardTaskLogStreamResponse {
|
||||
|
|
@ -112,21 +101,8 @@ function normalizeMemberName(value: string): string {
|
|||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isBoardMcpToolName(toolName: string | undefined): boolean {
|
||||
if (!toolName) return false;
|
||||
const canonical = canonicalizeBoardToolName(toolName);
|
||||
return (
|
||||
canonical !== null &&
|
||||
(HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) ||
|
||||
HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical))
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalizeBoardToolName(toolName: string | undefined): string | null {
|
||||
if (!toolName) return null;
|
||||
const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
const isBoardMcpToolName = isBoardTaskLogMcpToolName;
|
||||
const canonicalizeBoardToolName = canonicalizeBoardTaskLogToolName;
|
||||
|
||||
function normalizeTaskReference(value: unknown): string | null {
|
||||
if (typeof value !== 'string' && typeof value !== 'number') {
|
||||
|
|
@ -231,13 +207,28 @@ function normalizeRelationshipDetail(
|
|||
}
|
||||
|
||||
function inferHistoricalLinkKind(canonicalToolName: string): 'lifecycle' | 'board_action' | null {
|
||||
if (HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonicalToolName)) {
|
||||
return 'lifecycle';
|
||||
switch (canonicalToolName) {
|
||||
case 'task_complete':
|
||||
case 'task_set_status':
|
||||
case 'task_start':
|
||||
case 'review_approve':
|
||||
case 'review_request_changes':
|
||||
case 'review_start':
|
||||
return 'lifecycle';
|
||||
case 'review_request':
|
||||
case 'task_add_comment':
|
||||
case 'task_attach_comment_file':
|
||||
case 'task_attach_file':
|
||||
case 'task_get':
|
||||
case 'task_get_comment':
|
||||
case 'task_link':
|
||||
case 'task_set_clarification':
|
||||
case 'task_set_owner':
|
||||
case 'task_unlink':
|
||||
return 'board_action';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
if (HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonicalToolName)) {
|
||||
return 'board_action';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActivityCategory {
|
||||
|
|
@ -1388,10 +1379,9 @@ function collectAllowedMemberNames(
|
|||
|
||||
for (const record of records) {
|
||||
const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName);
|
||||
if (
|
||||
record.action?.category === 'read' ||
|
||||
(canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName))
|
||||
) {
|
||||
const isReadOnlyTool =
|
||||
canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment';
|
||||
if (record.action?.category === 'read' || isReadOnlyTool) {
|
||||
continue;
|
||||
}
|
||||
|
||||
|
|
@ -1578,15 +1568,73 @@ function mergeRuntimeFallbackResponse(
|
|||
fallback: BoardTaskLogStreamResponse
|
||||
): BoardTaskLogStreamResponse {
|
||||
const participants = mergeParticipants(primary.participants, fallback.participants);
|
||||
const source =
|
||||
fallback.source === 'codex_native_trace_fallback'
|
||||
? 'mixed_transcript_codex_native_trace'
|
||||
: 'mixed_transcript_opencode_runtime';
|
||||
return {
|
||||
participants,
|
||||
defaultFilter: chooseDefaultFilter(participants),
|
||||
segments: mergeSegments(primary.segments, fallback.segments),
|
||||
source: primary.source,
|
||||
source,
|
||||
runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection,
|
||||
};
|
||||
}
|
||||
|
||||
function collectNativeToolSignatures(response: BoardTaskLogStreamResponse): Set<string> {
|
||||
const signatures = new Set<string>();
|
||||
for (const segment of response.segments) {
|
||||
for (const chunk of segment.chunks) {
|
||||
const record = chunk as unknown as Record<string, unknown>;
|
||||
const toolExecutions = Array.isArray(record.toolExecutions) ? record.toolExecutions : [];
|
||||
for (const execution of toolExecutions) {
|
||||
const toolCall = (execution as Record<string, unknown>)?.toolCall as
|
||||
| { name?: string; input?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const signature = buildCodexNativeToolSignature({
|
||||
toolName: toolCall?.name,
|
||||
input: toolCall?.input,
|
||||
});
|
||||
if (signature) {
|
||||
signatures.add(signature);
|
||||
}
|
||||
}
|
||||
const semanticSteps = Array.isArray(record.semanticSteps) ? record.semanticSteps : [];
|
||||
for (const step of semanticSteps) {
|
||||
const content = (step as Record<string, unknown>)?.content as
|
||||
| { toolName?: string; toolInput?: Record<string, unknown> }
|
||||
| undefined;
|
||||
const signature = buildCodexNativeToolSignature({
|
||||
toolName: content?.toolName,
|
||||
input: content?.toolInput,
|
||||
});
|
||||
if (signature) {
|
||||
signatures.add(signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
function collectNativeToolSignaturesFromSlices(slices: StreamSlice[]): Set<string> {
|
||||
const signatures = new Set<string>();
|
||||
for (const slice of slices) {
|
||||
for (const message of slice.filteredMessages) {
|
||||
for (const toolCall of message.toolCalls) {
|
||||
const signature = buildCodexNativeToolSignature({
|
||||
toolName: toolCall.name,
|
||||
input: toolCall.input,
|
||||
});
|
||||
if (signature) {
|
||||
signatures.add(signature);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return signatures;
|
||||
}
|
||||
|
||||
export class BoardTaskLogStreamService {
|
||||
private readonly layoutCache = new Map<
|
||||
string,
|
||||
|
|
@ -1613,9 +1661,12 @@ export class BoardTaskLogStreamService {
|
|||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||
private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(),
|
||||
private readonly openCodeRuntimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader()
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly codexNativeTraceFallbackSource: CodexNativeTaskLogStreamSource = new CodexNativeTaskLogStreamSource(),
|
||||
private readonly transcriptCandidateSelector: TaskLogTranscriptCandidateSelector = new TaskLogTranscriptCandidateSelector(),
|
||||
private readonly historicalBoardMcpRawProbe: HistoricalBoardMcpRawProbe = new HistoricalBoardMcpRawProbe()
|
||||
) {}
|
||||
|
||||
private buildLayoutCacheKey(teamName: string, taskId: string): string {
|
||||
|
|
@ -1695,7 +1746,23 @@ export class BoardTaskLogStreamService {
|
|||
}
|
||||
|
||||
const transcriptFiles = transcriptContext?.transcriptFiles ?? [];
|
||||
const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath));
|
||||
const candidateSelection = this.transcriptCandidateSelector.selectInferredNativeTranscriptFiles(
|
||||
{
|
||||
records,
|
||||
transcriptFiles,
|
||||
projectDir: transcriptContext?.projectDir,
|
||||
alreadyParsedFilePaths: new Set(parsedMessagesByFile.keys()),
|
||||
}
|
||||
);
|
||||
if (
|
||||
candidateSelection.diagnostics.finalCandidateCount >= INFERRED_CANDIDATE_SELECTION_WARN_COUNT
|
||||
) {
|
||||
logger.warn(
|
||||
`Broad inferred native task-log candidate selection: team=${teamName} task=${taskId} files=${candidateSelection.diagnostics.finalCandidateCount} recordFiles=${candidateSelection.diagnostics.recordFileCount} sessions=${candidateSelection.diagnostics.nonReadSessionCount} reason=${candidateSelection.diagnostics.reason}`
|
||||
);
|
||||
}
|
||||
|
||||
const missingFiles = candidateSelection.filePaths;
|
||||
let mergedParsedMessagesByFile = parsedMessagesByFile;
|
||||
if (missingFiles.length > 0) {
|
||||
const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles);
|
||||
|
|
@ -1800,7 +1867,27 @@ export class BoardTaskLogStreamService {
|
|||
};
|
||||
}
|
||||
|
||||
const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles);
|
||||
const rawProbe = await this.historicalBoardMcpRawProbe.findCandidateFiles({
|
||||
task,
|
||||
transcriptFiles,
|
||||
});
|
||||
if (
|
||||
rawProbe.elapsedMs >= HISTORICAL_RAW_PROBE_WARN_MS ||
|
||||
rawProbe.scannedFileCount >= HISTORICAL_RAW_PROBE_WARN_FILE_COUNT
|
||||
) {
|
||||
logger.warn(
|
||||
`Historical board MCP raw probe: team=${teamName} task=${taskId} scanned=${rawProbe.scannedFileCount} hits=${rawProbe.hitCount} elapsedMs=${rawProbe.elapsedMs}`
|
||||
);
|
||||
}
|
||||
if (rawProbe.filePaths.length === 0) {
|
||||
return {
|
||||
task,
|
||||
parsedMessagesByFile: new Map(),
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
|
||||
const parsedMessagesByFile = await this.strictParser.parseFiles(rawProbe.filePaths);
|
||||
const taskRefs = buildTaskReferenceSet(task);
|
||||
const leadName =
|
||||
transcriptContext?.config.members
|
||||
|
|
@ -2020,7 +2107,9 @@ export class BoardTaskLogStreamService {
|
|||
};
|
||||
}
|
||||
|
||||
const candidateFilePaths = candidates.map((candidate) => candidate.source.filePath);
|
||||
const candidateFilePaths = [
|
||||
...new Set(candidates.map((candidate) => candidate.source.filePath)),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
const parsedMessagesByFileForCandidates =
|
||||
parsedMessagesByFile &&
|
||||
candidateFilePaths.every((filePath) => parsedMessagesByFile?.has(filePath))
|
||||
|
|
@ -2128,12 +2217,12 @@ export class BoardTaskLogStreamService {
|
|||
}
|
||||
}
|
||||
|
||||
private async loadRuntimeFallback(
|
||||
private async loadOpenCodeRuntimeFallback(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
): Promise<BoardTaskLogStreamResponse | null> {
|
||||
const startedAt = Date.now();
|
||||
const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId);
|
||||
const fallback = await this.openCodeRuntimeFallbackSource.getTaskLogStream(teamName, taskId);
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
||||
logger.warn(
|
||||
|
|
@ -2145,6 +2234,26 @@ export class BoardTaskLogStreamService {
|
|||
return fallback;
|
||||
}
|
||||
|
||||
private async loadCodexNativeTraceFallback(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
excludeNativeToolSignatures?: ReadonlySet<string>
|
||||
): Promise<BoardTaskLogStreamResponse | null> {
|
||||
const startedAt = Date.now();
|
||||
const fallback = await this.codexNativeTraceFallbackSource.getTaskLogStream(teamName, taskId, {
|
||||
excludeNativeToolSignatures,
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
||||
logger.warn(
|
||||
`Slow task-log Codex native trace fallback: team=${teamName} task=${taskId} hit=${Boolean(
|
||||
fallback
|
||||
)} elapsedMs=${elapsedMs}`
|
||||
);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async getTaskLogStreamSummary(
|
||||
teamName: string,
|
||||
taskId: string
|
||||
|
|
@ -2155,11 +2264,22 @@ export class BoardTaskLogStreamService {
|
|||
|
||||
const layout = await this.getStreamLayout(teamName, taskId);
|
||||
if (layout.visibleSlices.length === 0) {
|
||||
return emptySummary();
|
||||
const codexFallback = await this.loadCodexNativeTraceFallback(teamName, taskId);
|
||||
if (codexFallback) {
|
||||
return { segmentCount: codexFallback.segments.length };
|
||||
}
|
||||
const runtimeFallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId);
|
||||
return runtimeFallback ? { segmentCount: runtimeFallback.segments.length } : emptySummary();
|
||||
}
|
||||
|
||||
const codexFallback = await this.loadCodexNativeTraceFallback(
|
||||
teamName,
|
||||
taskId,
|
||||
collectNativeToolSignaturesFromSlices(layout.visibleSlices)
|
||||
);
|
||||
return {
|
||||
segmentCount: countSegmentsFromSlices(layout.visibleSlices),
|
||||
segmentCount:
|
||||
countSegmentsFromSlices(layout.visibleSlices) + (codexFallback?.segments.length ?? 0),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -2170,7 +2290,11 @@ export class BoardTaskLogStreamService {
|
|||
|
||||
const layout = await this.getStreamLayout(teamName, taskId);
|
||||
if (layout.visibleSlices.length === 0) {
|
||||
const fallback = await this.loadRuntimeFallback(teamName, taskId);
|
||||
const codexFallback = await this.loadCodexNativeTraceFallback(teamName, taskId);
|
||||
if (codexFallback) {
|
||||
return codexFallback;
|
||||
}
|
||||
const fallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId);
|
||||
return fallback ?? emptyResponse();
|
||||
}
|
||||
|
||||
|
|
@ -2219,18 +2343,29 @@ export class BoardTaskLogStreamService {
|
|||
}
|
||||
flushSegment();
|
||||
|
||||
const primaryResponse: BoardTaskLogStreamResponse = {
|
||||
let primaryResponse: BoardTaskLogStreamResponse = {
|
||||
participants: layout.participants,
|
||||
defaultFilter: chooseDefaultFilter(layout.participants),
|
||||
segments,
|
||||
source: 'transcript',
|
||||
};
|
||||
|
||||
if (!layout.shouldMergeRuntimeFallback) {
|
||||
return primaryResponse;
|
||||
if (layout.shouldMergeRuntimeFallback) {
|
||||
const fallback = await this.loadOpenCodeRuntimeFallback(teamName, taskId);
|
||||
if (fallback) {
|
||||
primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, fallback);
|
||||
}
|
||||
}
|
||||
|
||||
const fallback = await this.loadRuntimeFallback(teamName, taskId);
|
||||
return fallback ? mergeRuntimeFallbackResponse(primaryResponse, fallback) : primaryResponse;
|
||||
const codexFallback = await this.loadCodexNativeTraceFallback(
|
||||
teamName,
|
||||
taskId,
|
||||
collectNativeToolSignatures(primaryResponse)
|
||||
);
|
||||
if (codexFallback) {
|
||||
primaryResponse = mergeRuntimeFallbackResponse(primaryResponse, codexFallback);
|
||||
}
|
||||
|
||||
return primaryResponse;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,173 @@
|
|||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
import { TeamConfigReader } from '../../TeamConfigReader';
|
||||
import { TeamMembersMetaStore } from '../../TeamMembersMetaStore';
|
||||
import { TeamTaskReader } from '../../TeamTaskReader';
|
||||
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
|
||||
import { isCodexNativeTraceFallbackEnabled } from '../exact/featureGates';
|
||||
|
||||
import { CodexNativeTraceProjector } from './CodexNativeTraceProjector';
|
||||
import { CodexNativeTraceReader } from './CodexNativeTraceReader';
|
||||
|
||||
import type {
|
||||
BoardTaskLogActor,
|
||||
BoardTaskLogParticipant,
|
||||
BoardTaskLogSegment,
|
||||
BoardTaskLogStreamResponse,
|
||||
TeamTask,
|
||||
} from '@shared/types';
|
||||
|
||||
function normalizeMemberName(value: string): string {
|
||||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function buildParticipantKey(memberName: string): string {
|
||||
return `member:${normalizeMemberName(memberName)}`;
|
||||
}
|
||||
|
||||
function buildParticipant(memberName: string): BoardTaskLogParticipant {
|
||||
return {
|
||||
key: buildParticipantKey(memberName),
|
||||
label: memberName,
|
||||
role: 'member',
|
||||
isLead: false,
|
||||
isSidechain: false,
|
||||
};
|
||||
}
|
||||
|
||||
function buildActor(memberName: string, sessionId: string): BoardTaskLogActor {
|
||||
return {
|
||||
memberName,
|
||||
role: 'member',
|
||||
sessionId,
|
||||
isSidechain: false,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodexNativeTaskLogStreamSource {
|
||||
constructor(
|
||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader(),
|
||||
private readonly traceReader: CodexNativeTraceReader = new CodexNativeTraceReader(),
|
||||
private readonly projector: CodexNativeTraceProjector = new CodexNativeTraceProjector(),
|
||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
|
||||
) {}
|
||||
|
||||
async getTaskLogStream(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
options: { excludeNativeToolSignatures?: ReadonlySet<string> } = {}
|
||||
): Promise<BoardTaskLogStreamResponse | null> {
|
||||
if (!isCodexNativeTraceFallbackEnabled()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const task = await this.resolveTask(teamName, taskId);
|
||||
if (!task) {
|
||||
return null;
|
||||
}
|
||||
const ownerName = task.owner?.trim();
|
||||
if (!ownerName) {
|
||||
return null;
|
||||
}
|
||||
if (!(await this.isCodexOwner(teamName, ownerName))) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayId = getTaskDisplayId(task);
|
||||
const candidateTaskIds = [
|
||||
...new Set([task.id, displayId, task.id.slice(0, 8)].filter(Boolean)),
|
||||
];
|
||||
const runs = await this.traceReader.readTaskRuns({
|
||||
teamName,
|
||||
taskIds: candidateTaskIds,
|
||||
includeIncoming: task.status === 'in_progress',
|
||||
});
|
||||
if (runs.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const excludedSignatures = options.excludeNativeToolSignatures ?? new Set<string>();
|
||||
const messages = this.projector.project(runs, {
|
||||
excludeSignatures: excludedSignatures,
|
||||
});
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const chunks = this.chunkBuilder.buildBundleChunks(messages);
|
||||
if (chunks.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const participant = buildParticipant(ownerName);
|
||||
const firstMessage = messages[0];
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!firstMessage || !lastMessage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const nativeToolCount = messages.filter((message) => message.toolCalls.length > 0).length;
|
||||
const totalNativeToolCount =
|
||||
excludedSignatures.size > 0
|
||||
? this.projector.project(runs).filter((message) => message.toolCalls.length > 0).length
|
||||
: nativeToolCount;
|
||||
const dedupedNativeToolCount = Math.max(0, totalNativeToolCount - nativeToolCount);
|
||||
|
||||
const segment: BoardTaskLogSegment = {
|
||||
id: `codex-native:${teamName}:${task.id}:${normalizeMemberName(ownerName)}`,
|
||||
participantKey: participant.key,
|
||||
actor: buildActor(ownerName, runs[0]?.runId ?? firstMessage.sessionId),
|
||||
startTimestamp: firstMessage.timestamp.toISOString(),
|
||||
endTimestamp: lastMessage.timestamp.toISOString(),
|
||||
chunks,
|
||||
};
|
||||
|
||||
return {
|
||||
participants: [participant],
|
||||
defaultFilter: participant.key,
|
||||
segments: [segment],
|
||||
source: 'codex_native_trace_fallback',
|
||||
runtimeProjection: {
|
||||
provider: 'codex_native',
|
||||
mode: 'trace',
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: messages.length,
|
||||
nativeToolCount,
|
||||
fallbackReason: 'codex_native_trace',
|
||||
traceFileCount: new Set(runs.map((run) => run.filePath)).size,
|
||||
traceRunCount: runs.length,
|
||||
dedupedNativeToolCount,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private async resolveTask(teamName: string, taskId: string): Promise<TeamTask | null> {
|
||||
const [activeTasks, deletedTasks] = await Promise.all([
|
||||
this.taskReader.getTasks(teamName).catch(() => []),
|
||||
this.taskReader.getDeletedTasks(teamName).catch(() => []),
|
||||
]);
|
||||
const normalizedRef = taskId.trim().replace(/^#/, '').toLowerCase();
|
||||
return (
|
||||
[...activeTasks, ...deletedTasks].find((candidate) => {
|
||||
const displayId = getTaskDisplayId(candidate);
|
||||
return [candidate.id, displayId, candidate.id.slice(0, 8)]
|
||||
.map((value) => value.trim().replace(/^#/, '').toLowerCase())
|
||||
.includes(normalizedRef);
|
||||
}) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
private async isCodexOwner(teamName: string, ownerName: string): Promise<boolean> {
|
||||
const normalizedOwner = normalizeMemberName(ownerName);
|
||||
const [metaMembers, config] = await Promise.all([
|
||||
this.membersMetaStore.getMembers(teamName).catch(() => []),
|
||||
this.configReader.getConfig(teamName).catch(() => null),
|
||||
]);
|
||||
const member = [...metaMembers, ...(config?.members ?? [])].find(
|
||||
(candidate) => normalizeMemberName(candidate.name) === normalizedOwner
|
||||
) as { providerId?: string } | undefined;
|
||||
return member?.providerId === 'codex';
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,271 @@
|
|||
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
|
||||
|
||||
import type { CodexNativeTraceEvent, CodexNativeTraceRun } from './CodexNativeTraceReader';
|
||||
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
||||
|
||||
export function buildCodexNativeToolSignature(args: {
|
||||
toolName?: string;
|
||||
input?: Record<string, unknown>;
|
||||
}): string | null {
|
||||
const toolName = args.toolName?.trim();
|
||||
if (!toolName || toolName.startsWith('mcp__')) {
|
||||
return null;
|
||||
}
|
||||
const input = args.input ?? {};
|
||||
if (toolName === 'Bash') {
|
||||
const command = typeof input.command === 'string' ? input.command.trim() : '';
|
||||
return command ? `${toolName}:${command}` : null;
|
||||
}
|
||||
if (toolName === 'Edit') {
|
||||
const filePath =
|
||||
typeof input.file_path === 'string' && input.file_path.trim().length > 0
|
||||
? input.file_path.trim()
|
||||
: Array.isArray(input.changes)
|
||||
? input.changes
|
||||
.map((change) =>
|
||||
change && typeof change === 'object' && 'path' in change
|
||||
? String((change as Record<string, unknown>).path ?? '').trim()
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
.join(',')
|
||||
: '';
|
||||
return filePath ? `${toolName}:${filePath}` : null;
|
||||
}
|
||||
return `${toolName}:${JSON.stringify(input)}`;
|
||||
}
|
||||
|
||||
function resultContent(result: unknown): string {
|
||||
if (typeof result === 'string') {
|
||||
return result;
|
||||
}
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
const record = result as Record<string, unknown>;
|
||||
if (typeof record.content === 'string') {
|
||||
return record.content;
|
||||
}
|
||||
if (typeof record.stderr === 'string' && record.stderr.trim().length > 0) {
|
||||
return record.stderr;
|
||||
}
|
||||
if (typeof record.message === 'string') {
|
||||
return record.message;
|
||||
}
|
||||
}
|
||||
return result == null ? '' : JSON.stringify(result);
|
||||
}
|
||||
|
||||
function asToolUseResult(
|
||||
result: unknown,
|
||||
fallback: {
|
||||
toolName: string;
|
||||
toolUseId: string;
|
||||
isError: boolean;
|
||||
}
|
||||
): ToolUseResultData {
|
||||
const content = resultContent(result);
|
||||
if (result && typeof result === 'object' && !Array.isArray(result)) {
|
||||
return {
|
||||
...(result as Record<string, unknown>),
|
||||
content,
|
||||
toolName: fallback.toolName,
|
||||
toolUseId: fallback.toolUseId,
|
||||
isError: fallback.isError,
|
||||
};
|
||||
}
|
||||
return {
|
||||
content,
|
||||
toolName: fallback.toolName,
|
||||
toolUseId: fallback.toolUseId,
|
||||
isError: fallback.isError,
|
||||
};
|
||||
}
|
||||
|
||||
function baseMessage(params: {
|
||||
uuid: string;
|
||||
type: 'assistant' | 'user';
|
||||
timestamp: Date;
|
||||
content: ContentBlock[];
|
||||
role?: 'assistant' | 'user';
|
||||
cwd?: string;
|
||||
sessionId: string;
|
||||
agentName?: string;
|
||||
isMeta?: boolean;
|
||||
}): ParsedMessage {
|
||||
const message: ParsedMessage = {
|
||||
uuid: params.uuid,
|
||||
parentUuid: null,
|
||||
type: params.type,
|
||||
timestamp: params.timestamp,
|
||||
content: params.content,
|
||||
sessionId: params.sessionId,
|
||||
isSidechain: false,
|
||||
isMeta: params.isMeta ?? false,
|
||||
toolCalls: extractToolCalls(params.content),
|
||||
toolResults: extractToolResults(params.content),
|
||||
};
|
||||
|
||||
if (params.role) {
|
||||
message.role = params.role;
|
||||
}
|
||||
|
||||
if (params.type === 'assistant') {
|
||||
message.model = '<synthetic>';
|
||||
}
|
||||
|
||||
if (params.cwd) {
|
||||
message.cwd = params.cwd;
|
||||
}
|
||||
|
||||
if (params.agentName) {
|
||||
message.agentName = params.agentName;
|
||||
}
|
||||
|
||||
return message;
|
||||
}
|
||||
|
||||
function buildSyntheticToolUseId(run: CodexNativeTraceRun, itemId: string): string {
|
||||
return `codex-trace:${run.teamName ?? 'unknown'}:${run.taskId ?? 'unknown'}:${run.runId}:${itemId}`;
|
||||
}
|
||||
|
||||
function buildToolStartMessage(
|
||||
run: CodexNativeTraceRun,
|
||||
event: CodexNativeTraceEvent
|
||||
): ParsedMessage | null {
|
||||
const projection = event.projection;
|
||||
if (!projection?.itemId || !projection.toolName) {
|
||||
return null;
|
||||
}
|
||||
const toolUseId = buildSyntheticToolUseId(run, projection.itemId);
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: toolUseId,
|
||||
name: projection.toolName,
|
||||
input: projection.input ?? {},
|
||||
},
|
||||
];
|
||||
return baseMessage({
|
||||
uuid: `${toolUseId}:start`,
|
||||
timestamp: new Date(event.receivedAt),
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content,
|
||||
sessionId: run.runId,
|
||||
...(run.cwd ? { cwd: run.cwd } : {}),
|
||||
...(run.ownerName ? { agentName: run.ownerName } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
function buildToolResultMessage(
|
||||
run: CodexNativeTraceRun,
|
||||
event: CodexNativeTraceEvent
|
||||
): ParsedMessage | null {
|
||||
const projection = event.projection;
|
||||
if (!projection?.itemId || !projection.toolName) {
|
||||
return null;
|
||||
}
|
||||
const toolUseId = buildSyntheticToolUseId(run, projection.itemId);
|
||||
const contentText = resultContent(projection.result);
|
||||
const isError = projection.isError === true;
|
||||
const content: ContentBlock[] = [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: contentText,
|
||||
...(isError ? { is_error: true } : {}),
|
||||
},
|
||||
];
|
||||
const toolUseResult = asToolUseResult(projection.result, {
|
||||
toolName: projection.toolName,
|
||||
toolUseId,
|
||||
isError,
|
||||
});
|
||||
return {
|
||||
...baseMessage({
|
||||
uuid: `${toolUseId}:result`,
|
||||
timestamp: new Date(event.receivedAt),
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
content,
|
||||
sessionId: run.runId,
|
||||
isMeta: true,
|
||||
...(run.cwd ? { cwd: run.cwd } : {}),
|
||||
...(run.ownerName ? { agentName: run.ownerName } : {}),
|
||||
}),
|
||||
sourceToolUseID: toolUseId,
|
||||
toolUseResult,
|
||||
};
|
||||
}
|
||||
|
||||
export class CodexNativeTraceProjector {
|
||||
project(
|
||||
runs: CodexNativeTraceRun[],
|
||||
options: { excludeSignatures?: ReadonlySet<string> } = {}
|
||||
): ParsedMessage[] {
|
||||
const messages: ParsedMessage[] = [];
|
||||
for (const run of runs) {
|
||||
const items = new Map<
|
||||
string,
|
||||
{
|
||||
firstOrder: number;
|
||||
start?: CodexNativeTraceEvent;
|
||||
result?: CodexNativeTraceEvent;
|
||||
}
|
||||
>();
|
||||
for (const event of run.events) {
|
||||
const projection = event.projection;
|
||||
if (!projection || projection.toolSource !== 'native') {
|
||||
continue;
|
||||
}
|
||||
if (!projection.itemId) {
|
||||
continue;
|
||||
}
|
||||
const current = items.get(projection.itemId) ?? { firstOrder: event.sourceOrder };
|
||||
current.firstOrder = Math.min(current.firstOrder, event.sourceOrder);
|
||||
if (projection.kind === 'tool_start') {
|
||||
current.start = event;
|
||||
} else if (projection.kind === 'tool_result') {
|
||||
current.result = event;
|
||||
}
|
||||
items.set(projection.itemId, current);
|
||||
}
|
||||
|
||||
for (const item of [...items.values()].sort(
|
||||
(left, right) => left.firstOrder - right.firstOrder
|
||||
)) {
|
||||
const projection = item.result?.projection ?? item.start?.projection;
|
||||
const signature = buildCodexNativeToolSignature({
|
||||
toolName: projection?.toolName,
|
||||
input: projection?.input,
|
||||
});
|
||||
if (signature && options.excludeSignatures?.has(signature)) {
|
||||
continue;
|
||||
}
|
||||
const start =
|
||||
item.start ??
|
||||
(item.result
|
||||
? {
|
||||
...item.result,
|
||||
projection: {
|
||||
...item.result.projection!,
|
||||
kind: 'tool_start' as const,
|
||||
},
|
||||
}
|
||||
: null);
|
||||
if (start) {
|
||||
const startMessage = buildToolStartMessage(run, start);
|
||||
if (startMessage) {
|
||||
messages.push(startMessage);
|
||||
}
|
||||
}
|
||||
if (item.result) {
|
||||
const resultMessage = buildToolResultMessage(run, item.result);
|
||||
if (resultMessage) {
|
||||
messages.push(resultMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return messages.sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime());
|
||||
}
|
||||
}
|
||||
377
src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts
Normal file
377
src/main/services/team/taskLogs/stream/CodexNativeTraceReader.ts
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
|
||||
import { getTeamsBasePath } from '@main/utils/pathDecoder';
|
||||
|
||||
const TRACE_ROOT_SEGMENT = path.join('.member-work-sync', 'runtime-hooks', 'codex-native-traces');
|
||||
|
||||
export interface CodexNativeTraceProjection {
|
||||
kind: 'tool_start' | 'tool_result' | 'message' | 'meta';
|
||||
toolSource?: 'mcp' | 'native';
|
||||
rawItemType?: string;
|
||||
itemId?: string;
|
||||
toolName?: string;
|
||||
status?: string;
|
||||
input?: Record<string, unknown>;
|
||||
result?: unknown;
|
||||
isError?: boolean;
|
||||
text?: string;
|
||||
}
|
||||
|
||||
export interface CodexNativeTraceEvent {
|
||||
sourceOrder: number;
|
||||
receivedAt: string;
|
||||
projection: CodexNativeTraceProjection | null;
|
||||
}
|
||||
|
||||
export interface CodexNativeTraceRun {
|
||||
filePath: string;
|
||||
runId: string;
|
||||
teamName: string | null;
|
||||
taskId: string | null;
|
||||
ownerName: string | null;
|
||||
cwd: string | null;
|
||||
startedAt: string | null;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
events: CodexNativeTraceEvent[];
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
interface TraceFileCandidate {
|
||||
filePath: string;
|
||||
mtimeMs: number;
|
||||
size: number;
|
||||
partial: boolean;
|
||||
}
|
||||
|
||||
function safeSegment(value: string): string {
|
||||
const encoded = encodeURIComponent(value);
|
||||
return encoded.length > 0 && encoded.length <= 160
|
||||
? encoded
|
||||
: `segment-${Buffer.from(value).toString('base64url').slice(0, 80)}`;
|
||||
}
|
||||
|
||||
function tracePathSegment(value: string): string | null {
|
||||
const trimmed = value.trim();
|
||||
return trimmed.length > 0 ? safeSegment(trimmed) : null;
|
||||
}
|
||||
|
||||
function isString(value: string | null): value is string {
|
||||
return typeof value === 'string';
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function readString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readRawString(record: Record<string, unknown>, key: string): string | null {
|
||||
const value = record[key];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function readNumber(record: Record<string, unknown>, key: string): number | null {
|
||||
const value = record[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : null;
|
||||
}
|
||||
|
||||
function normalizeIdentity(value: string | null): string | null {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim().toLowerCase() : null;
|
||||
}
|
||||
|
||||
function readProjection(value: unknown): CodexNativeTraceProjection | null {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
return null;
|
||||
}
|
||||
const kind = readString(record, 'kind');
|
||||
if (kind !== 'tool_start' && kind !== 'tool_result' && kind !== 'message' && kind !== 'meta') {
|
||||
return null;
|
||||
}
|
||||
const toolSource = readString(record, 'toolSource');
|
||||
return {
|
||||
kind,
|
||||
...(toolSource === 'mcp' || toolSource === 'native' ? { toolSource } : {}),
|
||||
...(readString(record, 'rawItemType')
|
||||
? { rawItemType: readString(record, 'rawItemType')! }
|
||||
: {}),
|
||||
...(readString(record, 'itemId') ? { itemId: readString(record, 'itemId')! } : {}),
|
||||
...(readString(record, 'toolName') ? { toolName: readString(record, 'toolName')! } : {}),
|
||||
...(readString(record, 'status') ? { status: readString(record, 'status')! } : {}),
|
||||
...(asRecord(record.input) ? { input: asRecord(record.input)! } : {}),
|
||||
...(Object.prototype.hasOwnProperty.call(record, 'result') ? { result: record.result } : {}),
|
||||
...(typeof record.isError === 'boolean' ? { isError: record.isError } : {}),
|
||||
...(readString(record, 'text') ? { text: readString(record, 'text')! } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function readProjectionFromRaw(value: unknown): CodexNativeTraceProjection | null {
|
||||
const event = asRecord(value);
|
||||
const item = asRecord(event?.item);
|
||||
const eventType = readString(event ?? {}, 'type');
|
||||
const itemType = readString(item ?? {}, 'type');
|
||||
const itemId = readString(item ?? {}, 'id');
|
||||
if (!item || !itemId || (eventType !== 'item.started' && eventType !== 'item.completed')) {
|
||||
return null;
|
||||
}
|
||||
if (itemType === 'command_execution') {
|
||||
const command = readString(item, 'command') ?? '';
|
||||
const status =
|
||||
readString(item, 'status') ?? (eventType === 'item.started' ? 'in_progress' : 'unknown');
|
||||
const exitCode = readNumber(item, 'exit_code') ?? readNumber(item, 'exitCode');
|
||||
const output =
|
||||
readRawString(item, 'aggregated_output') ??
|
||||
readRawString(item, 'output') ??
|
||||
readRawString(item, 'stderr') ??
|
||||
'';
|
||||
return {
|
||||
kind: eventType === 'item.started' ? 'tool_start' : 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId,
|
||||
toolName: 'Bash',
|
||||
status,
|
||||
input: { command },
|
||||
...(eventType === 'item.completed'
|
||||
? {
|
||||
result: {
|
||||
content: output,
|
||||
stdout:
|
||||
readRawString(item, 'aggregated_output') ?? readRawString(item, 'output') ?? '',
|
||||
stderr: readRawString(item, 'stderr') ?? '',
|
||||
exitCode,
|
||||
},
|
||||
isError:
|
||||
status === 'failed' || status === 'declined' || (exitCode !== null && exitCode !== 0),
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
if (itemType === 'file_change') {
|
||||
const changes = Array.isArray(item.changes) ? item.changes : [];
|
||||
const firstChange = changes.map(asRecord).find((change) => typeof change?.path === 'string');
|
||||
return {
|
||||
kind: eventType === 'item.started' ? 'tool_start' : 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'file_change',
|
||||
itemId,
|
||||
toolName: 'Edit',
|
||||
status:
|
||||
readString(item, 'status') ?? (eventType === 'item.started' ? 'in_progress' : 'unknown'),
|
||||
input: {
|
||||
file_path: typeof firstChange?.path === 'string' ? firstChange.path : '',
|
||||
changes,
|
||||
},
|
||||
...(eventType === 'item.completed'
|
||||
? {
|
||||
result: {
|
||||
content: [
|
||||
'File changes:',
|
||||
...changes.map((change) => {
|
||||
const row = asRecord(change);
|
||||
return `- ${row?.path ?? '(unknown path)'} (${row?.kind ?? 'update'})`;
|
||||
}),
|
||||
].join('\n'),
|
||||
changes,
|
||||
},
|
||||
isError: readString(item, 'status') === 'failed',
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isPathInside(parent: string, child: string): boolean {
|
||||
const relative = path.relative(parent, child);
|
||||
return Boolean(relative) && !relative.startsWith('..') && !path.isAbsolute(relative);
|
||||
}
|
||||
|
||||
export class CodexNativeTraceReader {
|
||||
constructor(private readonly teamsBasePath: string = getTeamsBasePath()) {}
|
||||
|
||||
getTraceRoot(): string {
|
||||
return path.join(this.teamsBasePath, TRACE_ROOT_SEGMENT);
|
||||
}
|
||||
|
||||
async readTaskRuns(params: {
|
||||
teamName: string;
|
||||
taskIds: string[];
|
||||
includeIncoming?: boolean;
|
||||
}): Promise<CodexNativeTraceRun[]> {
|
||||
const root = this.getTraceRoot();
|
||||
const rootResolved = path.resolve(root);
|
||||
const teamSegments = [...new Set([tracePathSegment(params.teamName)].filter(isString))];
|
||||
const taskSegments = [...new Set(params.taskIds.map(tracePathSegment).filter(isString))];
|
||||
const candidates: TraceFileCandidate[] = [];
|
||||
|
||||
for (const teamSegment of teamSegments) {
|
||||
for (const taskSegment of taskSegments) {
|
||||
candidates.push(
|
||||
...(await this.listTraceFiles(
|
||||
path.join(root, 'processed', teamSegment, taskSegment),
|
||||
false
|
||||
))
|
||||
);
|
||||
if (params.includeIncoming) {
|
||||
candidates.push(
|
||||
...(await this.listTraceFiles(
|
||||
path.join(root, 'incoming', teamSegment, taskSegment),
|
||||
true
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const uniqueCandidates = new Map<string, TraceFileCandidate>();
|
||||
for (const candidate of candidates) {
|
||||
const resolved = path.resolve(candidate.filePath);
|
||||
if (!isPathInside(rootResolved, resolved)) {
|
||||
continue;
|
||||
}
|
||||
uniqueCandidates.set(resolved, candidate);
|
||||
}
|
||||
|
||||
const parsedRuns = await Promise.all(
|
||||
[...uniqueCandidates.values()]
|
||||
.sort((left, right) => right.mtimeMs - left.mtimeMs)
|
||||
.slice(0, 10)
|
||||
.map((candidate) => this.readRun(candidate).catch(() => null))
|
||||
);
|
||||
const expectedTeamName = normalizeIdentity(params.teamName);
|
||||
const expectedTaskIds = new Set(
|
||||
params.taskIds
|
||||
.map((taskId) => normalizeIdentity(taskId))
|
||||
.filter((taskId): taskId is string => taskId !== null)
|
||||
);
|
||||
const runsById = new Map<string, CodexNativeTraceRun>();
|
||||
|
||||
for (const run of parsedRuns) {
|
||||
if (!run) {
|
||||
continue;
|
||||
}
|
||||
const runTeamName = normalizeIdentity(run.teamName);
|
||||
if (runTeamName && expectedTeamName && runTeamName !== expectedTeamName) {
|
||||
continue;
|
||||
}
|
||||
const runTaskId = normalizeIdentity(run.taskId);
|
||||
if (runTaskId && expectedTaskIds.size > 0 && !expectedTaskIds.has(runTaskId)) {
|
||||
continue;
|
||||
}
|
||||
const key = `${runTeamName ?? expectedTeamName ?? 'unknown-team'}::${runTaskId ?? 'unknown-task'}::${run.runId}`;
|
||||
const existing = runsById.get(key);
|
||||
if (
|
||||
!existing ||
|
||||
(existing.partial && !run.partial) ||
|
||||
(existing.partial === run.partial && run.mtimeMs > existing.mtimeMs)
|
||||
) {
|
||||
runsById.set(key, run);
|
||||
}
|
||||
}
|
||||
|
||||
return [...runsById.values()].sort((left, right) => {
|
||||
const leftTime = Date.parse(left.startedAt ?? '');
|
||||
const rightTime = Date.parse(right.startedAt ?? '');
|
||||
if (Number.isFinite(leftTime) && Number.isFinite(rightTime) && leftTime !== rightTime) {
|
||||
return leftTime - rightTime;
|
||||
}
|
||||
return left.filePath.localeCompare(right.filePath);
|
||||
});
|
||||
}
|
||||
|
||||
private async listTraceFiles(dir: string, partial: boolean): Promise<TraceFileCandidate[]> {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
||||
const rows = await Promise.all(
|
||||
entries
|
||||
.filter(
|
||||
(entry) =>
|
||||
entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name.endsWith('.jsonl.tmp'))
|
||||
)
|
||||
.map(async (entry) => {
|
||||
const filePath = path.join(dir, entry.name);
|
||||
const stat = await fs.stat(filePath).catch(() => null);
|
||||
return stat?.isFile()
|
||||
? {
|
||||
filePath,
|
||||
mtimeMs: stat.mtimeMs,
|
||||
size: stat.size,
|
||||
partial: partial || entry.name.endsWith('.tmp'),
|
||||
}
|
||||
: null;
|
||||
})
|
||||
);
|
||||
return rows.filter((row): row is TraceFileCandidate => row !== null);
|
||||
}
|
||||
|
||||
private async readRun(candidate: TraceFileCandidate): Promise<CodexNativeTraceRun | null> {
|
||||
const raw = await fs.readFile(candidate.filePath, 'utf8').catch((error) => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
return null;
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
if (raw === null) {
|
||||
return null;
|
||||
}
|
||||
const lines = raw.split(/\r?\n/);
|
||||
let header: Record<string, unknown> | null = null;
|
||||
const events: CodexNativeTraceEvent[] = [];
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index]?.trim();
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
let parsed: Record<string, unknown>;
|
||||
try {
|
||||
parsed = JSON.parse(line) as Record<string, unknown>;
|
||||
} catch {
|
||||
if (candidate.partial && index === lines.length - 1) {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (parsed.recordType === 'codex_native_trace_header') {
|
||||
header = parsed;
|
||||
continue;
|
||||
}
|
||||
if (parsed.recordType !== 'codex_native_stdout_event') {
|
||||
continue;
|
||||
}
|
||||
const sourceOrder =
|
||||
typeof parsed.sourceOrder === 'number' ? parsed.sourceOrder : events.length + 1;
|
||||
events.push({
|
||||
sourceOrder,
|
||||
receivedAt: readString(parsed, 'receivedAt') ?? new Date(candidate.mtimeMs).toISOString(),
|
||||
projection: readProjection(parsed.projection) ?? readProjectionFromRaw(parsed.raw),
|
||||
});
|
||||
}
|
||||
|
||||
if (!header) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
filePath: candidate.filePath,
|
||||
runId:
|
||||
readString(header, 'runId') ??
|
||||
path.basename(candidate.filePath).replace(/\.jsonl(?:\.tmp)?$/, ''),
|
||||
teamName: readString(header, 'teamName'),
|
||||
taskId: readString(header, 'taskId'),
|
||||
ownerName: readString(header, 'ownerName'),
|
||||
cwd: readString(header, 'cwd'),
|
||||
startedAt: readString(header, 'startedAt'),
|
||||
mtimeMs: candidate.mtimeMs,
|
||||
size: candidate.size,
|
||||
events: events.sort((left, right) => left.sourceOrder - right.sourceOrder),
|
||||
partial: candidate.partial,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,150 @@
|
|||
import { createReadStream } from 'fs';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
|
||||
import type { TeamTask } from '@shared/types';
|
||||
|
||||
const RAW_PROBE_CONCURRENCY = process.platform === 'win32' ? 4 : 8;
|
||||
const BOARD_MCP_MARKERS = [
|
||||
'mcp__agent-teams__task_',
|
||||
'mcp__agent-teams__review_',
|
||||
'mcp__agent_teams__task_',
|
||||
'mcp__agent_teams__review_',
|
||||
'agent-teams_task_',
|
||||
'agent-teams_review_',
|
||||
'agent_teams_task_',
|
||||
'agent_teams_review_',
|
||||
'"task_start"',
|
||||
'"task_complete"',
|
||||
'"task_add_comment"',
|
||||
'"task_set_status"',
|
||||
'"review_start"',
|
||||
'"review_request"',
|
||||
'"review_approve"',
|
||||
'"review_request_changes"',
|
||||
];
|
||||
|
||||
export interface HistoricalBoardMcpRawProbeInput {
|
||||
task: TeamTask;
|
||||
transcriptFiles: readonly string[];
|
||||
}
|
||||
|
||||
export interface HistoricalBoardMcpRawProbeResult {
|
||||
filePaths: string[];
|
||||
scannedFileCount: number;
|
||||
hitCount: number;
|
||||
elapsedMs: number;
|
||||
}
|
||||
|
||||
async function mapLimit<T, R>(
|
||||
items: readonly T[],
|
||||
limit: number,
|
||||
fn: (item: T) => Promise<R>
|
||||
): Promise<R[]> {
|
||||
const results = new Array<R>(items.length);
|
||||
let index = 0;
|
||||
const workerCount = Math.max(1, Math.min(limit, items.length));
|
||||
const workers = new Array(workerCount).fill(0).map(async () => {
|
||||
while (true) {
|
||||
const currentIndex = index;
|
||||
index += 1;
|
||||
if (currentIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
results[currentIndex] = await fn(items[currentIndex]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
return results;
|
||||
}
|
||||
|
||||
function normalizeReference(value: string | undefined): string | null {
|
||||
const normalized = value?.trim().replace(/^#/, '').toLowerCase();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function buildRawTaskReferences(task: TeamTask): string[] {
|
||||
const refs = new Set<string>();
|
||||
for (const value of [task.id, getTaskDisplayId(task)]) {
|
||||
const normalized = normalizeReference(value);
|
||||
if (!normalized) {
|
||||
continue;
|
||||
}
|
||||
refs.add(normalized);
|
||||
refs.add(`#${normalized}`);
|
||||
}
|
||||
return [...refs].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function textHasBoardMcpMarker(lowerText: string): boolean {
|
||||
return BOARD_MCP_MARKERS.some((marker) => lowerText.includes(marker));
|
||||
}
|
||||
|
||||
function textReferencesTask(lowerText: string, taskRefs: readonly string[]): boolean {
|
||||
return taskRefs.some((taskRef) => lowerText.includes(taskRef));
|
||||
}
|
||||
|
||||
async function fileHasTaskBoardMcpCandidate(
|
||||
filePath: string,
|
||||
taskRefs: readonly string[]
|
||||
): Promise<boolean> {
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
crlfDelay: Infinity,
|
||||
});
|
||||
|
||||
let hasTaskRef = false;
|
||||
let hasBoardMarker = false;
|
||||
try {
|
||||
for await (const line of rl) {
|
||||
const lowerLine = line.toLowerCase();
|
||||
hasTaskRef = hasTaskRef || textReferencesTask(lowerLine, taskRefs);
|
||||
hasBoardMarker = hasBoardMarker || textHasBoardMcpMarker(lowerLine);
|
||||
if (hasTaskRef && hasBoardMarker) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
rl.close();
|
||||
stream.destroy();
|
||||
}
|
||||
}
|
||||
|
||||
export class HistoricalBoardMcpRawProbe {
|
||||
async findCandidateFiles(
|
||||
input: HistoricalBoardMcpRawProbeInput
|
||||
): Promise<HistoricalBoardMcpRawProbeResult> {
|
||||
const startedAt = Date.now();
|
||||
const uniqueFiles = [...new Set(input.transcriptFiles)].sort((left, right) =>
|
||||
left.localeCompare(right)
|
||||
);
|
||||
const taskRefs = buildRawTaskReferences(input.task);
|
||||
if (uniqueFiles.length === 0 || taskRefs.length === 0) {
|
||||
return {
|
||||
filePaths: [],
|
||||
scannedFileCount: uniqueFiles.length,
|
||||
hitCount: 0,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
|
||||
const hits = await mapLimit(uniqueFiles, RAW_PROBE_CONCURRENCY, async (filePath) => {
|
||||
try {
|
||||
return (await fileHasTaskBoardMcpCandidate(filePath, taskRefs)) ? filePath : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
});
|
||||
|
||||
const filePaths = hits.filter((filePath): filePath is string => filePath !== null);
|
||||
return {
|
||||
filePaths,
|
||||
scannedFileCount: uniqueFiles.length,
|
||||
hitCount: filePaths.length,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,212 @@
|
|||
import path from 'path';
|
||||
|
||||
import { isReadOnlyBoardTaskLogToolName } from './boardTaskLogToolNames';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||
|
||||
export type TaskLogTranscriptCandidateReason =
|
||||
| 'direct_record_file'
|
||||
| 'same_session_non_read_record';
|
||||
|
||||
export interface TaskLogTranscriptCandidateFile {
|
||||
filePath: string;
|
||||
reason: TaskLogTranscriptCandidateReason;
|
||||
sessionId?: string;
|
||||
sourceRecordIds: string[];
|
||||
}
|
||||
|
||||
export interface TaskLogTranscriptCandidateSelectionDiagnostics {
|
||||
recordFileCount: number;
|
||||
nonReadSessionCount: number;
|
||||
sameSessionFileCount: number;
|
||||
alreadyParsedCandidateCount: number;
|
||||
finalCandidateCount: number;
|
||||
reason: 'direct_record_files' | 'same_session_native_window' | 'no_candidates';
|
||||
}
|
||||
|
||||
export interface TaskLogTranscriptCandidateSelection {
|
||||
filePaths: string[];
|
||||
candidates: TaskLogTranscriptCandidateFile[];
|
||||
diagnostics: TaskLogTranscriptCandidateSelectionDiagnostics;
|
||||
}
|
||||
|
||||
export interface SelectInferredNativeTranscriptFilesInput {
|
||||
records: readonly BoardTaskActivityRecord[];
|
||||
transcriptFiles: readonly string[];
|
||||
projectDir?: string;
|
||||
alreadyParsedFilePaths?: ReadonlySet<string>;
|
||||
}
|
||||
|
||||
interface TranscriptSessionIndex {
|
||||
filesBySessionId: Map<string, string[]>;
|
||||
sessionIdByFilePath: Map<string, string>;
|
||||
}
|
||||
|
||||
function normalizeSessionId(value: string | undefined): string | null {
|
||||
const normalized = value?.trim();
|
||||
return normalized ? normalized : null;
|
||||
}
|
||||
|
||||
function extractTranscriptSessionId(
|
||||
projectDir: string | undefined,
|
||||
filePath: string
|
||||
): string | null {
|
||||
if (!projectDir) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(projectDir, filePath);
|
||||
if (!relativePath || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = relativePath.split(path.sep).filter(Boolean);
|
||||
if (parts.length === 1 && parts[0]?.endsWith('.jsonl')) {
|
||||
return parts[0].slice(0, -'.jsonl'.length);
|
||||
}
|
||||
|
||||
if (parts.length >= 3 && parts[1] === 'subagents' && parts[2]?.endsWith('.jsonl')) {
|
||||
return parts[0] ?? null;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildTranscriptSessionIndex(
|
||||
transcriptFiles: readonly string[],
|
||||
projectDir: string | undefined
|
||||
): TranscriptSessionIndex {
|
||||
const filesBySessionId = new Map<string, string[]>();
|
||||
const sessionIdByFilePath = new Map<string, string>();
|
||||
|
||||
for (const filePath of transcriptFiles) {
|
||||
const sessionId = extractTranscriptSessionId(projectDir, filePath);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
sessionIdByFilePath.set(filePath, sessionId);
|
||||
const files = filesBySessionId.get(sessionId) ?? [];
|
||||
files.push(filePath);
|
||||
filesBySessionId.set(sessionId, files);
|
||||
}
|
||||
|
||||
for (const [sessionId, files] of filesBySessionId.entries()) {
|
||||
filesBySessionId.set(
|
||||
sessionId,
|
||||
[...new Set(files)].sort((left, right) => left.localeCompare(right))
|
||||
);
|
||||
}
|
||||
|
||||
return { filesBySessionId, sessionIdByFilePath };
|
||||
}
|
||||
|
||||
function isReadOnlyRecord(record: BoardTaskActivityRecord): boolean {
|
||||
return (
|
||||
record.action?.category === 'read' ||
|
||||
isReadOnlyBoardTaskLogToolName(record.action?.canonicalToolName)
|
||||
);
|
||||
}
|
||||
|
||||
function addCandidate(
|
||||
candidatesByFilePath: Map<string, TaskLogTranscriptCandidateFile>,
|
||||
filePath: string,
|
||||
candidate: Omit<TaskLogTranscriptCandidateFile, 'filePath'>
|
||||
): void {
|
||||
const existing = candidatesByFilePath.get(filePath);
|
||||
if (!existing) {
|
||||
candidatesByFilePath.set(filePath, {
|
||||
filePath,
|
||||
...candidate,
|
||||
sourceRecordIds: [...new Set(candidate.sourceRecordIds)].sort((left, right) =>
|
||||
left.localeCompare(right)
|
||||
),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
existing.sourceRecordIds = [
|
||||
...new Set([...existing.sourceRecordIds, ...candidate.sourceRecordIds]),
|
||||
].sort((left, right) => left.localeCompare(right));
|
||||
|
||||
if (existing.reason !== 'direct_record_file' && candidate.reason === 'direct_record_file') {
|
||||
existing.reason = candidate.reason;
|
||||
}
|
||||
if (!existing.sessionId && candidate.sessionId) {
|
||||
existing.sessionId = candidate.sessionId;
|
||||
}
|
||||
}
|
||||
|
||||
export class TaskLogTranscriptCandidateSelector {
|
||||
selectInferredNativeTranscriptFiles(
|
||||
input: SelectInferredNativeTranscriptFilesInput
|
||||
): TaskLogTranscriptCandidateSelection {
|
||||
const alreadyParsedFilePaths = input.alreadyParsedFilePaths ?? new Set<string>();
|
||||
const sessionIndex = buildTranscriptSessionIndex(input.transcriptFiles, input.projectDir);
|
||||
const candidatesByFilePath = new Map<string, TaskLogTranscriptCandidateFile>();
|
||||
const recordFiles = new Set<string>();
|
||||
const nonReadSessionIds = new Set<string>();
|
||||
const sameSessionFiles = new Set<string>();
|
||||
|
||||
for (const record of input.records) {
|
||||
if (record.source.filePath) {
|
||||
recordFiles.add(record.source.filePath);
|
||||
addCandidate(candidatesByFilePath, record.source.filePath, {
|
||||
reason: 'direct_record_file',
|
||||
sessionId:
|
||||
normalizeSessionId(record.actor.sessionId) ??
|
||||
sessionIndex.sessionIdByFilePath.get(record.source.filePath),
|
||||
sourceRecordIds: [record.id],
|
||||
});
|
||||
}
|
||||
|
||||
if (isReadOnlyRecord(record)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sessionId =
|
||||
normalizeSessionId(record.actor.sessionId) ??
|
||||
sessionIndex.sessionIdByFilePath.get(record.source.filePath);
|
||||
if (!sessionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nonReadSessionIds.add(sessionId);
|
||||
for (const filePath of sessionIndex.filesBySessionId.get(sessionId) ?? []) {
|
||||
sameSessionFiles.add(filePath);
|
||||
addCandidate(candidatesByFilePath, filePath, {
|
||||
reason: 'same_session_non_read_record',
|
||||
sessionId,
|
||||
sourceRecordIds: [record.id],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const candidates = [...candidatesByFilePath.values()].sort((left, right) =>
|
||||
left.filePath.localeCompare(right.filePath)
|
||||
);
|
||||
const filePaths = candidates
|
||||
.map((candidate) => candidate.filePath)
|
||||
.filter((filePath) => !alreadyParsedFilePaths.has(filePath));
|
||||
|
||||
const alreadyParsedCandidateCount = candidates.length - filePaths.length;
|
||||
const reason =
|
||||
candidates.length === 0
|
||||
? 'no_candidates'
|
||||
: nonReadSessionIds.size > 0
|
||||
? 'same_session_native_window'
|
||||
: 'direct_record_files';
|
||||
|
||||
return {
|
||||
filePaths,
|
||||
candidates,
|
||||
diagnostics: {
|
||||
recordFileCount: recordFiles.size,
|
||||
nonReadSessionCount: nonReadSessionIds.size,
|
||||
sameSessionFileCount: sameSessionFiles.size,
|
||||
alreadyParsedCandidateCount,
|
||||
finalCandidateCount: filePaths.length,
|
||||
reason,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames';
|
||||
|
||||
const HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES = new Set([
|
||||
'task_complete',
|
||||
'task_set_status',
|
||||
'task_start',
|
||||
'review_approve',
|
||||
'review_request_changes',
|
||||
'review_start',
|
||||
]);
|
||||
|
||||
const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([
|
||||
'review_request',
|
||||
'task_add_comment',
|
||||
'task_attach_comment_file',
|
||||
'task_attach_file',
|
||||
'task_get',
|
||||
'task_get_comment',
|
||||
'task_link',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_unlink',
|
||||
]);
|
||||
|
||||
const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']);
|
||||
|
||||
export function canonicalizeBoardTaskLogToolName(toolName: string | undefined): string | null {
|
||||
if (!toolName) return null;
|
||||
const normalized = canonicalizeAgentTeamsToolName(toolName).trim().toLowerCase();
|
||||
return normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
export function isBoardTaskLogMcpToolName(toolName: string | undefined): boolean {
|
||||
const canonical = canonicalizeBoardTaskLogToolName(toolName);
|
||||
return (
|
||||
canonical !== null &&
|
||||
(HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) ||
|
||||
HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical))
|
||||
);
|
||||
}
|
||||
|
||||
export function isReadOnlyBoardTaskLogToolName(toolName: string | undefined): boolean {
|
||||
const canonical = canonicalizeBoardTaskLogToolName(toolName);
|
||||
return canonical !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonical);
|
||||
}
|
||||
|
||||
export function isRecoverableHistoricalBoardTaskLogToolName(toolName: string | undefined): boolean {
|
||||
const canonical = canonicalizeBoardTaskLogToolName(toolName);
|
||||
return (
|
||||
canonical !== null &&
|
||||
(HISTORICAL_BOARD_LIFECYCLE_TOOL_NAMES.has(canonical) ||
|
||||
HISTORICAL_BOARD_ACTION_TOOL_NAMES.has(canonical))
|
||||
);
|
||||
}
|
||||
|
|
@ -22,6 +22,7 @@ interface MemberExecutionLogProps {
|
|||
memberName?: string;
|
||||
memberColor?: string;
|
||||
teamName?: string;
|
||||
hideMemberHeading?: boolean;
|
||||
}
|
||||
|
||||
type ExpandedItemIdsByGroup = Map<string, Set<string>>;
|
||||
|
|
@ -31,6 +32,7 @@ export const MemberExecutionLog = ({
|
|||
memberName,
|
||||
memberColor,
|
||||
teamName,
|
||||
hideMemberHeading,
|
||||
}: MemberExecutionLogProps): React.JSX.Element => {
|
||||
const conversation = useMemo(() => transformChunksToConversation(chunks, [], false), [chunks]);
|
||||
|
||||
|
|
@ -69,6 +71,7 @@ export const MemberExecutionLog = ({
|
|||
memberName={memberName}
|
||||
memberColor={memberColor}
|
||||
teamName={teamName}
|
||||
hideMemberHeading={hideMemberHeading}
|
||||
expanded={!collapsedGroupIds.has(item.group.id)}
|
||||
expandedItemIds={expandedItemIdsByGroup.get(item.group.id) ?? new Set()}
|
||||
onToggleExpanded={() => {
|
||||
|
|
@ -162,6 +165,7 @@ interface AIExecutionGroupProps {
|
|||
memberName?: string;
|
||||
memberColor?: string;
|
||||
teamName?: string;
|
||||
hideMemberHeading?: boolean;
|
||||
expanded: boolean;
|
||||
expandedItemIds: Set<string>;
|
||||
onToggleExpanded: () => void;
|
||||
|
|
@ -173,6 +177,7 @@ const AIExecutionGroup = ({
|
|||
memberName,
|
||||
memberColor,
|
||||
teamName,
|
||||
hideMemberHeading,
|
||||
expanded,
|
||||
expandedItemIds,
|
||||
onToggleExpanded,
|
||||
|
|
@ -210,7 +215,7 @@ const AIExecutionGroup = ({
|
|||
onClick={onToggleExpanded}
|
||||
aria-expanded={expanded}
|
||||
>
|
||||
{normalizedMemberName ? (
|
||||
{normalizedMemberName && !hideMemberHeading ? (
|
||||
<>
|
||||
<MemberBadge
|
||||
name={normalizedMemberName}
|
||||
|
|
@ -223,6 +228,10 @@ const AIExecutionGroup = ({
|
|||
turn
|
||||
</span>
|
||||
</>
|
||||
) : hideMemberHeading ? (
|
||||
<span className="shrink-0 text-xs font-semibold text-[var(--color-text-secondary)]">
|
||||
turn
|
||||
</span>
|
||||
) : (
|
||||
<>
|
||||
<Bot className="size-4 shrink-0 text-[var(--color-text-secondary)]" />
|
||||
|
|
|
|||
|
|
@ -94,9 +94,18 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string
|
|||
}
|
||||
return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.';
|
||||
}
|
||||
if (stream?.source === 'codex_native_trace_fallback') {
|
||||
return 'Task-scoped Codex native trace logs projected into the same execution-log components used in Logs.';
|
||||
}
|
||||
if (stream?.source === 'mixed_transcript_codex_native_trace') {
|
||||
return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.';
|
||||
}
|
||||
if (stream?.runtimeProjection?.provider === 'opencode') {
|
||||
return 'Task-scoped transcript logs merged with OpenCode runtime logs and rendered with the same execution-log components used in Logs.';
|
||||
}
|
||||
if (stream?.runtimeProjection?.provider === 'codex_native') {
|
||||
return 'Task-scoped transcript logs merged with Codex native trace logs and rendered with the same execution-log components used in Logs.';
|
||||
}
|
||||
return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.';
|
||||
}
|
||||
|
||||
|
|
@ -142,9 +151,26 @@ function buildParticipantVisualMap(
|
|||
return visuals;
|
||||
}
|
||||
|
||||
const SegmentMarker = ({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element => {
|
||||
const SegmentMarker = ({
|
||||
segment,
|
||||
visual,
|
||||
teamName,
|
||||
}: {
|
||||
segment: BoardTaskLogSegment;
|
||||
visual?: ParticipantVisual;
|
||||
teamName: string;
|
||||
}): React.JSX.Element => {
|
||||
return (
|
||||
<div className="mb-2 flex items-center gap-1.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
|
||||
{visual ? (
|
||||
<MemberBadge
|
||||
name={visual.name}
|
||||
color={visual.color}
|
||||
teamName={teamName}
|
||||
size="xs"
|
||||
disableHoverCard
|
||||
/>
|
||||
) : null}
|
||||
<span className="flex items-center gap-1">
|
||||
<Clock size={10} />
|
||||
{formatRelativeTime(segment.endTimestamp)}
|
||||
|
|
@ -166,12 +192,13 @@ const SegmentBlock = ({
|
|||
}): React.JSX.Element => {
|
||||
return (
|
||||
<div className="min-w-0 overflow-hidden">
|
||||
{showHeader ? <SegmentMarker segment={segment} /> : null}
|
||||
{showHeader ? <SegmentMarker segment={segment} visual={visual} teamName={teamName} /> : null}
|
||||
<MemberExecutionLog
|
||||
chunks={segment.chunks}
|
||||
memberName={segment.actor.memberName}
|
||||
memberColor={visual?.color}
|
||||
teamName={teamName}
|
||||
hideMemberHeading={showHeader && Boolean(segment.actor.memberName)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -349,8 +349,8 @@ export interface BoardTaskLogSegment {
|
|||
}
|
||||
|
||||
export interface BoardTaskLogStreamRuntimeProjection {
|
||||
provider: 'opencode';
|
||||
mode: 'attribution' | 'heuristic';
|
||||
provider: 'opencode' | 'codex_native';
|
||||
mode: 'attribution' | 'heuristic' | 'trace';
|
||||
attributionRecordCount: number;
|
||||
projectedMessageCount: number;
|
||||
boardMcpToolCount?: number;
|
||||
|
|
@ -358,16 +358,26 @@ export interface BoardTaskLogStreamRuntimeProjection {
|
|||
fallbackReason?:
|
||||
| 'no_attribution_records'
|
||||
| 'attribution_no_projected_messages'
|
||||
| 'task_tool_markers';
|
||||
| 'task_tool_markers'
|
||||
| 'codex_native_trace';
|
||||
markerMatchCount?: number;
|
||||
markerSpanCount?: number;
|
||||
traceFileCount?: number;
|
||||
traceRunCount?: number;
|
||||
dedupedNativeToolCount?: number;
|
||||
}
|
||||
|
||||
export interface BoardTaskLogStreamResponse {
|
||||
participants: BoardTaskLogParticipant[];
|
||||
defaultFilter: 'all' | string;
|
||||
segments: BoardTaskLogSegment[];
|
||||
source?: 'transcript' | 'opencode_runtime_fallback' | 'opencode_runtime_attribution';
|
||||
source?:
|
||||
| 'transcript'
|
||||
| 'opencode_runtime_fallback'
|
||||
| 'opencode_runtime_attribution'
|
||||
| 'codex_native_trace_fallback'
|
||||
| 'mixed_transcript_codex_native_trace'
|
||||
| 'mixed_transcript_opencode_runtime';
|
||||
runtimeProjection?: BoardTaskLogStreamRuntimeProjection;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -136,4 +136,52 @@ describe('BoardTaskActivityRecordSource', () => {
|
|||
expect(transcriptReader.readFiles).toHaveBeenCalledTimes(1);
|
||||
expect(recordBuilder.buildForTasks).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('rebuilds the team index when transcript discovery generation changes', async () => {
|
||||
const task = {
|
||||
id: 'task-a',
|
||||
displayId: 'aaaa1111',
|
||||
subject: 'A',
|
||||
status: 'pending',
|
||||
};
|
||||
let generation = 0;
|
||||
const transcriptFiles = ['/tmp/a.jsonl'];
|
||||
const firstRecords = [{ id: 'record-a-1' }];
|
||||
const secondRecords = [{ id: 'record-a-2' }];
|
||||
|
||||
const locator = {
|
||||
getGeneration: vi.fn(() => generation),
|
||||
getContext: vi.fn(async () => ({
|
||||
transcriptFiles,
|
||||
})),
|
||||
};
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [task]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const transcriptReader = {
|
||||
readFiles: vi.fn(async () => [{ uuid: `m${generation}` }]),
|
||||
};
|
||||
const recordBuilder = {
|
||||
buildForTasks: vi
|
||||
.fn()
|
||||
.mockReturnValueOnce(new Map([['task-a', firstRecords]]))
|
||||
.mockReturnValueOnce(new Map([['task-a', secondRecords]])),
|
||||
};
|
||||
|
||||
const source = new BoardTaskActivityRecordSource(
|
||||
locator as never,
|
||||
taskReader as never,
|
||||
transcriptReader as never,
|
||||
recordBuilder as never,
|
||||
);
|
||||
|
||||
await expect(source.getTaskRecords('demo', 'task-a')).resolves.toEqual(firstRecords);
|
||||
generation += 1;
|
||||
await expect(source.getTaskRecords('demo', 'task-a')).resolves.toEqual(secondRecords);
|
||||
|
||||
expect(locator.getContext).toHaveBeenCalledTimes(2);
|
||||
expect(transcriptReader.readFiles).toHaveBeenCalledTimes(2);
|
||||
expect(recordBuilder.buildForTasks).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'fs/promises';
|
|||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
|
||||
import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource';
|
||||
|
|
@ -10,6 +10,7 @@ import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services
|
|||
import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
|
||||
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
|
||||
import type { TeamTask } from '../../../../src/shared/types';
|
||||
|
||||
const TEAM_NAME = 'beacon-desk-2';
|
||||
|
|
@ -465,4 +466,137 @@ describe('BoardTaskLogDiagnosticsService', () => {
|
|||
expect(report.stream.emptyPayloadExamples).toEqual([]);
|
||||
expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']);
|
||||
});
|
||||
|
||||
it('bounds diagnostics strict parsing to activity-record candidate files', async () => {
|
||||
const projectDir = path.join(tmpdir(), 'diagnostics-project');
|
||||
const rootFile = path.join(projectDir, 'session-tom.jsonl');
|
||||
const subagentFile = path.join(projectDir, 'session-tom', 'subagents', 'agent-work.jsonl');
|
||||
const unrelatedFile = path.join(projectDir, 'session-alice.jsonl');
|
||||
const task = createTask({ owner: 'tom' });
|
||||
const record: BoardTaskActivityRecord = {
|
||||
id: 'record-comment',
|
||||
timestamp: '2026-04-12T15:36:00.000Z',
|
||||
task: {
|
||||
locator: { ref: 'c414cd52', refKind: 'display', canonicalId: TASK_ID },
|
||||
resolution: 'resolved',
|
||||
},
|
||||
linkKind: 'board_action',
|
||||
targetRole: 'subject',
|
||||
actor: {
|
||||
memberName: 'tom',
|
||||
role: 'member',
|
||||
sessionId: 'session-tom',
|
||||
isSidechain: false,
|
||||
},
|
||||
actorContext: { relation: 'same_task' },
|
||||
action: {
|
||||
canonicalToolName: 'task_add_comment',
|
||||
toolUseId: 'tool-comment',
|
||||
category: 'comment',
|
||||
},
|
||||
source: {
|
||||
filePath: rootFile,
|
||||
messageUuid: 'message-comment',
|
||||
toolUseId: 'tool-comment',
|
||||
sourceOrder: 1,
|
||||
},
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: async (filePaths: string[]) =>
|
||||
new Map(filePaths.map((filePath) => [filePath, []])),
|
||||
};
|
||||
const parseSpy = vi.spyOn(strictParser, 'parseFiles');
|
||||
const diagnosticsService = new BoardTaskLogDiagnosticsService(
|
||||
{
|
||||
getTasks: async () => [task],
|
||||
getDeletedTasks: async () => [] as TeamTask[],
|
||||
} as never,
|
||||
{
|
||||
getContext: async () => ({
|
||||
projectDir,
|
||||
transcriptFiles: [rootFile, subagentFile, unrelatedFile],
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getTaskRecords: async () => [record],
|
||||
} as never,
|
||||
strictParser as never,
|
||||
{
|
||||
getTaskLogStream: async () => ({
|
||||
participants: [],
|
||||
defaultFilter: 'all' as const,
|
||||
segments: [],
|
||||
}),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID);
|
||||
|
||||
expect(parseSpy).toHaveBeenCalledWith([rootFile, subagentFile]);
|
||||
expect(parseSpy.mock.calls.flatMap((call) => call[0] as string[])).not.toContain(
|
||||
unrelatedFile,
|
||||
);
|
||||
expect(report.transcript.parsedFileCount).toBe(2);
|
||||
expect(report.transcript.candidateSelection).toMatchObject({
|
||||
mode: 'activity_records',
|
||||
candidateFileCount: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it('bounds diagnostics historical recovery parsing to raw-probe hit files', async () => {
|
||||
const task = createTask({ owner: 'tom' });
|
||||
const hitFile = path.join(tmpdir(), 'diagnostics-historical-hit.jsonl');
|
||||
const unrelatedFile = path.join(tmpdir(), 'diagnostics-historical-unrelated.jsonl');
|
||||
const strictParser = {
|
||||
parseFiles: async (filePaths: string[]) =>
|
||||
new Map(filePaths.map((filePath) => [filePath, []])),
|
||||
};
|
||||
const parseSpy = vi.spyOn(strictParser, 'parseFiles');
|
||||
const rawProbe = {
|
||||
findCandidateFiles: async () => ({
|
||||
filePaths: [hitFile],
|
||||
scannedFileCount: 2,
|
||||
hitCount: 1,
|
||||
elapsedMs: 0,
|
||||
}),
|
||||
};
|
||||
const diagnosticsService = new BoardTaskLogDiagnosticsService(
|
||||
{
|
||||
getTasks: async () => [task],
|
||||
getDeletedTasks: async () => [] as TeamTask[],
|
||||
} as never,
|
||||
{
|
||||
getContext: async () => ({
|
||||
projectDir: tmpdir(),
|
||||
transcriptFiles: [hitFile, unrelatedFile],
|
||||
}),
|
||||
} as never,
|
||||
{
|
||||
getTaskRecords: async () => [],
|
||||
} as never,
|
||||
strictParser as never,
|
||||
{
|
||||
getTaskLogStream: async () => ({
|
||||
participants: [],
|
||||
defaultFilter: 'all' as const,
|
||||
segments: [],
|
||||
}),
|
||||
} as never,
|
||||
undefined,
|
||||
rawProbe as never,
|
||||
);
|
||||
|
||||
const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID);
|
||||
|
||||
expect(parseSpy).toHaveBeenCalledWith([hitFile]);
|
||||
expect(parseSpy.mock.calls.flatMap((call) => call[0] as string[])).not.toContain(
|
||||
unrelatedFile,
|
||||
);
|
||||
expect(report.transcript.candidateSelection).toMatchObject({
|
||||
mode: 'historical_raw_probe',
|
||||
candidateFileCount: 1,
|
||||
rawProbeScannedFileCount: 2,
|
||||
rawProbeHitCount: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
|
||||
import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
import { setClaudeBasePathOverride } from '@main/utils/pathDecoder';
|
||||
|
||||
const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim();
|
||||
const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim();
|
||||
|
|
@ -54,6 +54,7 @@ describeLive('BoardTaskLogStream live smoke', () => {
|
|||
|
||||
const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId);
|
||||
expect(stream.segments.length).toBeGreaterThan(0);
|
||||
vi.mocked(console.warn).mockClear();
|
||||
|
||||
if (EXPECT_MISSING_WORKER_LINKS) {
|
||||
expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0);
|
||||
|
|
|
|||
|
|
@ -158,9 +158,9 @@ describe('BoardTaskLogStreamService', () => {
|
|||
expect(response.source).toBe('opencode_runtime_fallback');
|
||||
expect(response.segments).toHaveLength(1);
|
||||
expect(await service.getTaskLogStreamSummary('demo', 'task-a')).toEqual({
|
||||
segmentCount: 0,
|
||||
segmentCount: 1,
|
||||
});
|
||||
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1);
|
||||
expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('merges OpenCode runtime stream when board transcript slices mask member execution', async () => {
|
||||
|
|
@ -1089,6 +1089,225 @@ describe('BoardTaskLogStreamService', () => {
|
|||
expect(response.segments[0]?.participantKey).toBe('member:alice');
|
||||
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
|
||||
expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']);
|
||||
expect(strictParser.parseFiles).toHaveBeenCalledTimes(1);
|
||||
expect(strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[])).not.toContain(
|
||||
'/tmp/alice.jsonl'
|
||||
);
|
||||
});
|
||||
|
||||
it('limits inferred native parsing to direct and same-session transcript candidates', async () => {
|
||||
const projectDir = '/tmp/task-log-project';
|
||||
const rootFile = `${projectDir}/session-alice.jsonl`;
|
||||
const subagentFile = `${projectDir}/session-alice/subagents/agent-worker.jsonl`;
|
||||
const unrelatedFiles = Array.from(
|
||||
{ length: 300 },
|
||||
(_, index) => `${projectDir}/session-unrelated-${index}.jsonl`
|
||||
);
|
||||
const alice = {
|
||||
memberName: 'alice',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-alice',
|
||||
isSidechain: false,
|
||||
};
|
||||
const baseRecord = makeRecord(
|
||||
'alice-comment',
|
||||
'2026-04-12T16:00:00.000Z',
|
||||
alice,
|
||||
'tool-comment'
|
||||
);
|
||||
const commentRecord: BoardTaskActivityRecord = {
|
||||
...baseRecord,
|
||||
action: {
|
||||
canonicalToolName: 'task_add_comment',
|
||||
toolUseId: 'tool-comment',
|
||||
category: 'comment',
|
||||
},
|
||||
source: {
|
||||
...baseRecord.source,
|
||||
filePath: rootFile,
|
||||
},
|
||||
};
|
||||
const candidate: BoardTaskExactLogBundleCandidate = {
|
||||
...makeCandidate('alice-comment', '2026-04-12T16:00:00.000Z', alice, 'tool-comment'),
|
||||
source: commentRecord.source,
|
||||
records: [commentRecord],
|
||||
actionCategory: 'comment',
|
||||
canonicalToolName: 'task_add_comment',
|
||||
};
|
||||
const nativeMessage: ParsedMessage = {
|
||||
uuid: 'alice-bash',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-12T16:01:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-bash',
|
||||
name: 'Bash',
|
||||
input: { command: 'npm test' },
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-bash',
|
||||
name: 'Bash',
|
||||
input: { command: 'npm test' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
sessionId: 'session-alice',
|
||||
agentName: 'alice',
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
};
|
||||
const recordSource = {
|
||||
getTaskRecords: vi.fn(async () => [commentRecord]),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => [candidate]),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async (filePaths: string[]) =>
|
||||
new Map(
|
||||
filePaths.map((filePath) => [
|
||||
filePath,
|
||||
filePath === subagentFile ? [nativeMessage] : [],
|
||||
])
|
||||
)
|
||||
),
|
||||
};
|
||||
const detailSelector = {
|
||||
selectDetail: vi.fn(() => ({
|
||||
id: 'alice-comment',
|
||||
timestamp: '2026-04-12T16:00:00.000Z',
|
||||
actor: alice,
|
||||
source: candidate.source,
|
||||
records: [commentRecord],
|
||||
filteredMessages: [
|
||||
makeMessage('alice-comment-detail', '2026-04-12T16:00:00.000Z', 'comment'),
|
||||
],
|
||||
})),
|
||||
};
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
owner: 'alice',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-04-12T15:59:00.000Z',
|
||||
updatedAt: '2026-04-12T16:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
getContext: vi.fn(async () => ({
|
||||
projectDir,
|
||||
transcriptFiles: [rootFile, subagentFile, ...unrelatedFiles],
|
||||
config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] },
|
||||
})),
|
||||
};
|
||||
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
recordSource as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
detailSelector as never,
|
||||
{ buildBundleChunks } as never,
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
||||
{ getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]) } as never,
|
||||
{ getConfig: vi.fn(async () => null) } as never,
|
||||
{ getTaskLogStream: vi.fn(async () => null) } as never
|
||||
);
|
||||
|
||||
await service.getTaskLogStream('demo', 'task-a');
|
||||
|
||||
expect(strictParser.parseFiles.mock.calls.map((call) => call[0])).toEqual([
|
||||
[rootFile],
|
||||
[subagentFile],
|
||||
]);
|
||||
const parsedFiles = strictParser.parseFiles.mock.calls.flatMap((call) => call[0] as string[]);
|
||||
expect(parsedFiles).not.toEqual(expect.arrayContaining(unrelatedFiles));
|
||||
expect(buildBundleChunks.mock.calls[0]?.[0].map((message: ParsedMessage) => message.uuid)).toEqual([
|
||||
'alice-comment-detail',
|
||||
'alice-bash',
|
||||
]);
|
||||
});
|
||||
|
||||
it('limits historical board MCP recovery parsing to raw-probe candidate files', async () => {
|
||||
const hitFile = '/tmp/historical-hit.jsonl';
|
||||
const unrelatedFile = '/tmp/historical-unrelated.jsonl';
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
owner: 'tom',
|
||||
status: 'completed',
|
||||
createdAt: '2026-04-12T16:00:00.000Z',
|
||||
updatedAt: '2026-04-12T16:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
getContext: vi.fn(async () => ({
|
||||
transcriptFiles: [hitFile, unrelatedFile],
|
||||
config: {
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async () => new Map<string, ParsedMessage[]>([[hitFile, []]])),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => {
|
||||
throw new Error('empty parsed historical candidate should not create records');
|
||||
}),
|
||||
};
|
||||
const rawProbe = {
|
||||
findCandidateFiles: vi.fn(async () => ({
|
||||
filePaths: [hitFile],
|
||||
scannedFileCount: 2,
|
||||
hitCount: 1,
|
||||
elapsedMs: 0,
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
{ getTaskRecords: vi.fn(async () => []) } as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
{ getTaskLogStream: vi.fn(async () => null) } as never,
|
||||
undefined as never,
|
||||
rawProbe as never
|
||||
);
|
||||
|
||||
await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({
|
||||
participants: [],
|
||||
defaultFilter: 'all',
|
||||
segments: [],
|
||||
});
|
||||
expect(rawProbe.findCandidateFiles).toHaveBeenCalledWith({
|
||||
task: expect.objectContaining({ id: 'task-a' }),
|
||||
transcriptFiles: [hitFile, unrelatedFile],
|
||||
});
|
||||
expect(strictParser.parseFiles).toHaveBeenCalledWith([hitFile]);
|
||||
});
|
||||
|
||||
it('does not recover task_get logs from nested task refs in result payloads', async () => {
|
||||
|
|
@ -1595,4 +1814,129 @@ describe('BoardTaskLogStreamService', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
it('merges Codex native trace fallback even when primary transcript has MCP execution records', async () => {
|
||||
const atlas = {
|
||||
memberName: 'atlas',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-atlas',
|
||||
agentId: 'agent-atlas',
|
||||
isSidechain: true,
|
||||
};
|
||||
const baseCandidate = makeCandidate(
|
||||
'c1',
|
||||
'2026-05-01T17:10:00.000Z',
|
||||
atlas,
|
||||
'mcp-tool-1'
|
||||
);
|
||||
const executionRecord: BoardTaskActivityRecord = {
|
||||
...baseCandidate.records[0]!,
|
||||
linkKind: 'execution',
|
||||
};
|
||||
const candidate: BoardTaskExactLogBundleCandidate = {
|
||||
...baseCandidate,
|
||||
records: [executionRecord],
|
||||
linkKinds: ['execution'],
|
||||
};
|
||||
const recordSource = {
|
||||
getTaskRecords: vi.fn(async () => candidate.records),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => [candidate]),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async () => new Map([['/tmp/codex-task.jsonl', []]])),
|
||||
};
|
||||
const detailSelector = {
|
||||
selectDetail: vi.fn(() => ({
|
||||
id: candidate.id,
|
||||
timestamp: candidate.timestamp,
|
||||
actor: atlas,
|
||||
source: candidate.source,
|
||||
records: candidate.records,
|
||||
filteredMessages: [makeMessage('mcp-message', '2026-05-01T17:10:00.000Z', 'mcp task_start')],
|
||||
})),
|
||||
};
|
||||
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
||||
const openCodeRuntimeFallbackSource = {
|
||||
getTaskLogStream: vi.fn(async () => {
|
||||
throw new Error('OpenCode fallback should stay behind OpenCode-only conditions');
|
||||
}),
|
||||
};
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [{ name: 'atlas', providerId: 'codex' }]),
|
||||
};
|
||||
const configReader = {
|
||||
getConfig: vi.fn(async () => null),
|
||||
};
|
||||
const codexNativeTraceFallbackSource = {
|
||||
getTaskLogStream: vi.fn(async () => ({
|
||||
participants: [
|
||||
{
|
||||
key: 'member:atlas',
|
||||
label: 'atlas',
|
||||
role: 'member' as const,
|
||||
isLead: false,
|
||||
isSidechain: true,
|
||||
},
|
||||
],
|
||||
defaultFilter: 'member:atlas',
|
||||
segments: [
|
||||
{
|
||||
id: 'codex-native:demo:task-a:atlas',
|
||||
participantKey: 'member:atlas',
|
||||
actor: atlas,
|
||||
startTimestamp: '2026-05-01T17:10:02.000Z',
|
||||
endTimestamp: '2026-05-01T17:10:05.000Z',
|
||||
chunks: [{ id: 'bash-chunk' }],
|
||||
},
|
||||
],
|
||||
source: 'codex_native_trace_fallback' as const,
|
||||
runtimeProjection: {
|
||||
provider: 'codex_native' as const,
|
||||
mode: 'trace' as const,
|
||||
attributionRecordCount: 0,
|
||||
projectedMessageCount: 2,
|
||||
nativeToolCount: 1,
|
||||
fallbackReason: 'codex_native_trace' as const,
|
||||
traceFileCount: 1,
|
||||
traceRunCount: 1,
|
||||
dedupedNativeToolCount: 0,
|
||||
},
|
||||
})),
|
||||
};
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
recordSource as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
detailSelector as never,
|
||||
{ buildBundleChunks } as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
openCodeRuntimeFallbackSource as never,
|
||||
membersMetaStore as never,
|
||||
configReader as never,
|
||||
codexNativeTraceFallbackSource as never
|
||||
);
|
||||
|
||||
const response = await service.getTaskLogStream('demo', 'task-a');
|
||||
|
||||
expect(openCodeRuntimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled();
|
||||
expect(codexNativeTraceFallbackSource.getTaskLogStream).toHaveBeenCalledWith(
|
||||
'demo',
|
||||
'task-a',
|
||||
{ excludeNativeToolSignatures: expect.any(Set) }
|
||||
);
|
||||
expect(response.source).toBe('mixed_transcript_codex_native_trace');
|
||||
expect(response.participants.map((participant) => participant.key)).toEqual(['member:atlas']);
|
||||
expect(response.segments.map((segment) => segment.id)).toEqual([
|
||||
'member:atlas:c1:c1',
|
||||
'codex-native:demo:task-a:atlas',
|
||||
]);
|
||||
expect(response.runtimeProjection).toMatchObject({
|
||||
provider: 'codex_native',
|
||||
nativeToolCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
|
|
|||
133
test/main/services/team/CodexNativeTaskLogStreamSource.test.ts
Normal file
133
test/main/services/team/CodexNativeTaskLogStreamSource.test.ts
Normal file
|
|
@ -0,0 +1,133 @@
|
|||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { CodexNativeTaskLogStreamSource } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTaskLogStreamSource';
|
||||
|
||||
import type { ParsedMessage } from '../../../../src/main/types';
|
||||
import type { CodexNativeTraceRun } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader';
|
||||
import type { TeamTask } from '../../../../src/shared/types';
|
||||
|
||||
function task(overrides: Partial<TeamTask> = {}): TeamTask {
|
||||
return {
|
||||
id: '8421e1bb-2f3b-4656-9983-6e0fd4b15963',
|
||||
displayId: '8421e1bb',
|
||||
subject: 'Investigate Codex tools',
|
||||
owner: 'atlas',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-05-01T17:10:00.000Z',
|
||||
updatedAt: '2026-05-01T17:20:00.000Z',
|
||||
...overrides,
|
||||
} as TeamTask;
|
||||
}
|
||||
|
||||
function message(uuid: string, timestamp: string, toolName: string): ParsedMessage {
|
||||
const toolUseId = `${uuid}-tool`;
|
||||
return {
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
timestamp: new Date(timestamp),
|
||||
content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: { command: 'pwd' } } as never],
|
||||
toolCalls: [{ id: toolUseId, name: toolName, input: { command: 'pwd' }, isTask: false }],
|
||||
toolResults: [],
|
||||
sessionId: 'run-1',
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexNativeTaskLogStreamSource', () => {
|
||||
it('resolves short task refs, verifies Codex owner, and reads full/display/short trace candidates', async () => {
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [task()]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const membersMetaStore = {
|
||||
getMembers: vi.fn(async () => [{ name: 'atlas', providerId: 'codex' }]),
|
||||
};
|
||||
const configReader = {
|
||||
getConfig: vi.fn(async () => null),
|
||||
};
|
||||
const traceRuns: CodexNativeTraceRun[] = [
|
||||
{
|
||||
filePath: '/trace/run-1.jsonl',
|
||||
runId: 'run-1',
|
||||
teamName: 'vector-room-131313',
|
||||
taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963',
|
||||
ownerName: 'atlas',
|
||||
cwd: '/repo',
|
||||
startedAt: '2026-05-01T17:10:00.000Z',
|
||||
mtimeMs: Date.parse('2026-05-01T17:10:00.000Z'),
|
||||
size: 100,
|
||||
events: [],
|
||||
partial: false,
|
||||
},
|
||||
];
|
||||
const traceReader = {
|
||||
readTaskRuns: vi.fn(async () => traceRuns),
|
||||
};
|
||||
const projector = {
|
||||
project: vi.fn(() => [message('bash-start', '2026-05-01T17:10:02.000Z', 'Bash')]),
|
||||
};
|
||||
const chunkBuilder = {
|
||||
buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: 'chunk-1', messages }]),
|
||||
};
|
||||
|
||||
const source = new CodexNativeTaskLogStreamSource(
|
||||
taskReader as never,
|
||||
membersMetaStore as never,
|
||||
configReader as never,
|
||||
traceReader as never,
|
||||
projector as never,
|
||||
chunkBuilder as never
|
||||
);
|
||||
|
||||
const response = await source.getTaskLogStream('vector-room-131313', '#8421e1bb');
|
||||
|
||||
expect(traceReader.readTaskRuns).toHaveBeenCalledWith({
|
||||
teamName: 'vector-room-131313',
|
||||
taskIds: [
|
||||
'8421e1bb-2f3b-4656-9983-6e0fd4b15963',
|
||||
'8421e1bb',
|
||||
],
|
||||
includeIncoming: true,
|
||||
});
|
||||
expect(response).toMatchObject({
|
||||
defaultFilter: 'member:atlas',
|
||||
source: 'codex_native_trace_fallback',
|
||||
runtimeProjection: {
|
||||
provider: 'codex_native',
|
||||
mode: 'trace',
|
||||
nativeToolCount: 1,
|
||||
traceFileCount: 1,
|
||||
traceRunCount: 1,
|
||||
},
|
||||
});
|
||||
expect(response?.participants.map((participant) => participant.key)).toEqual(['member:atlas']);
|
||||
expect(response?.segments[0]?.participantKey).toBe('member:atlas');
|
||||
});
|
||||
|
||||
it('does not expose traces for non-Codex task owners', async () => {
|
||||
const traceReader = {
|
||||
readTaskRuns: vi.fn(async () => {
|
||||
throw new Error('should not read traces for non-Codex owners');
|
||||
}),
|
||||
};
|
||||
const source = new CodexNativeTaskLogStreamSource(
|
||||
{
|
||||
getTasks: vi.fn(async () => [task({ owner: 'alice' })]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
} as never,
|
||||
{
|
||||
getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'anthropic' }]),
|
||||
} as never,
|
||||
{
|
||||
getConfig: vi.fn(async () => null),
|
||||
} as never,
|
||||
traceReader as never
|
||||
);
|
||||
|
||||
await expect(source.getTaskLogStream('vector-room-131313', '8421e1bb')).resolves.toBeNull();
|
||||
expect(traceReader.readTaskRuns).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
179
test/main/services/team/CodexNativeTraceProjector.test.ts
Normal file
179
test/main/services/team/CodexNativeTraceProjector.test.ts
Normal file
|
|
@ -0,0 +1,179 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CodexNativeTraceProjector,
|
||||
buildCodexNativeToolSignature,
|
||||
} from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceProjector';
|
||||
|
||||
import type {
|
||||
CodexNativeTraceEvent,
|
||||
CodexNativeTraceRun,
|
||||
} from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader';
|
||||
|
||||
function run(overrides: Partial<CodexNativeTraceRun> = {}): CodexNativeTraceRun {
|
||||
return {
|
||||
filePath: '/trace/run-1.jsonl',
|
||||
runId: 'run-1',
|
||||
teamName: 'vector-room-131313',
|
||||
taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963',
|
||||
ownerName: 'atlas',
|
||||
cwd: '/repo',
|
||||
startedAt: '2026-05-01T17:10:07.799Z',
|
||||
mtimeMs: Date.parse('2026-05-01T17:10:07.799Z'),
|
||||
size: 100,
|
||||
partial: false,
|
||||
events: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function event(overrides: Partial<CodexNativeTraceEvent>): CodexNativeTraceEvent {
|
||||
return {
|
||||
sourceOrder: 1,
|
||||
receivedAt: '2026-05-01T17:10:08.000Z',
|
||||
projection: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexNativeTraceProjector', () => {
|
||||
it('projects native command result-only traces into a complete synthetic tool pair', () => {
|
||||
const messages = new CodexNativeTraceProjector().project([
|
||||
run({
|
||||
events: [
|
||||
event({
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd && ls' },
|
||||
result: {
|
||||
content: '/repo\nfile.txt\n',
|
||||
stdout: '/repo\nfile.txt\n',
|
||||
exitCode: 0,
|
||||
},
|
||||
isError: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
expect(messages).toHaveLength(2);
|
||||
expect(messages[0]).toMatchObject({
|
||||
type: 'assistant',
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'Bash',
|
||||
input: { command: 'pwd && ls' },
|
||||
},
|
||||
],
|
||||
agentName: 'atlas',
|
||||
cwd: '/repo',
|
||||
});
|
||||
expect(JSON.stringify(messages[0]?.content)).toContain(
|
||||
'codex-trace:vector-room-131313:8421e1bb-2f3b-4656-9983-6e0fd4b15963:run-1:item_1'
|
||||
);
|
||||
expect(messages[1]).toMatchObject({
|
||||
type: 'user',
|
||||
role: 'user',
|
||||
isMeta: true,
|
||||
sourceToolUseID:
|
||||
'codex-trace:vector-room-131313:8421e1bb-2f3b-4656-9983-6e0fd4b15963:run-1:item_1',
|
||||
toolUseResult: {
|
||||
content: '/repo\nfile.txt\n',
|
||||
stdout: '/repo\nfile.txt\n',
|
||||
exitCode: 0,
|
||||
toolName: 'Bash',
|
||||
isError: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('deduplicates by native signature without leaving orphan start or result messages', () => {
|
||||
const projector = new CodexNativeTraceProjector();
|
||||
const traceRun = run({
|
||||
events: [
|
||||
event({
|
||||
sourceOrder: 1,
|
||||
projection: {
|
||||
kind: 'tool_start',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
},
|
||||
}),
|
||||
event({
|
||||
sourceOrder: 2,
|
||||
receivedAt: '2026-05-01T17:10:09.000Z',
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
result: { content: '/repo\n' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(projector.project([traceRun])).toHaveLength(2);
|
||||
expect(
|
||||
projector.project([traceRun], {
|
||||
excludeSignatures: new Set([buildCodexNativeToolSignature({ toolName: 'Bash', input: { command: 'pwd' } })!]),
|
||||
})
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it('qualifies synthetic ids by run id so local Codex item ids do not collide', () => {
|
||||
const messages = new CodexNativeTraceProjector().project([
|
||||
run({
|
||||
runId: 'run-a',
|
||||
events: [
|
||||
event({
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
result: { content: 'a' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
run({
|
||||
runId: 'run-b',
|
||||
events: [
|
||||
event({
|
||||
receivedAt: '2026-05-01T17:11:08.000Z',
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'ls' },
|
||||
result: { content: 'b' },
|
||||
},
|
||||
}),
|
||||
],
|
||||
}),
|
||||
]);
|
||||
|
||||
const toolUseIds = messages
|
||||
.filter((message) => message.type === 'assistant')
|
||||
.map((message) => String(JSON.stringify(message.content)));
|
||||
|
||||
expect(toolUseIds[0]).toContain(':run-a:item_1');
|
||||
expect(toolUseIds[1]).toContain(':run-b:item_1');
|
||||
expect(new Set(toolUseIds).size).toBe(2);
|
||||
});
|
||||
});
|
||||
239
test/main/services/team/CodexNativeTraceReader.test.ts
Normal file
239
test/main/services/team/CodexNativeTraceReader.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { mkdtemp, mkdir, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { CodexNativeTraceReader } from '../../../../src/main/services/team/taskLogs/stream/CodexNativeTraceReader';
|
||||
|
||||
const TRACE_ROOT_SEGMENT = path.join('.member-work-sync', 'runtime-hooks', 'codex-native-traces');
|
||||
|
||||
let teamsBasePath: string;
|
||||
|
||||
function traceSegment(value: string): string {
|
||||
return encodeURIComponent(value);
|
||||
}
|
||||
|
||||
async function writeTraceFile(params: {
|
||||
bucket: 'incoming' | 'processed';
|
||||
teamName: string;
|
||||
taskId: string;
|
||||
runId: string;
|
||||
records: Array<Record<string, unknown>>;
|
||||
suffix?: '.jsonl' | '.jsonl.tmp';
|
||||
}): Promise<string> {
|
||||
const dir = path.join(
|
||||
teamsBasePath,
|
||||
TRACE_ROOT_SEGMENT,
|
||||
params.bucket,
|
||||
traceSegment(params.teamName),
|
||||
traceSegment(params.taskId)
|
||||
);
|
||||
await mkdir(dir, { recursive: true });
|
||||
const absolutePath = path.join(dir, `${params.runId}${params.suffix ?? (params.bucket === 'incoming' ? '.jsonl.tmp' : '.jsonl')}`);
|
||||
await writeFile(
|
||||
absolutePath,
|
||||
`${params.records.map((record) => JSON.stringify(record)).join('\n')}\n`,
|
||||
'utf8'
|
||||
);
|
||||
return absolutePath;
|
||||
}
|
||||
|
||||
function header(overrides: Partial<Record<string, unknown>> = {}): Record<string, unknown> {
|
||||
return {
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_trace_header',
|
||||
runId: 'run-1',
|
||||
teamName: 'vector-room-131313',
|
||||
taskId: '8421e1bb-2f3b-4656-9983-6e0fd4b15963',
|
||||
ownerName: 'atlas',
|
||||
provider: 'codex',
|
||||
cwd: '/repo',
|
||||
startedAt: '2026-05-01T17:10:07.799Z',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('CodexNativeTraceReader', () => {
|
||||
beforeEach(async () => {
|
||||
teamsBasePath = await mkdtemp(path.join(tmpdir(), 'codex-native-trace-reader-'));
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(teamsBasePath, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reads projection records and prefers processed trace over duplicate incoming run', async () => {
|
||||
const teamName = 'vector-room-131313';
|
||||
const taskId = '8421e1bb-2f3b-4656-9983-6e0fd4b15963';
|
||||
await writeTraceFile({
|
||||
bucket: 'incoming',
|
||||
teamName,
|
||||
taskId,
|
||||
runId: 'run-1',
|
||||
records: [
|
||||
header(),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_stdout_event',
|
||||
receivedAt: '2026-05-01T17:10:08.000Z',
|
||||
sourceOrder: 1,
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
result: { content: 'incoming' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
await writeTraceFile({
|
||||
bucket: 'processed',
|
||||
teamName,
|
||||
taskId,
|
||||
runId: 'run-1',
|
||||
records: [
|
||||
header(),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_stdout_event',
|
||||
receivedAt: '2026-05-01T17:10:08.000Z',
|
||||
sourceOrder: 1,
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
rawItemType: 'command_execution',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
input: { command: 'pwd' },
|
||||
result: { content: 'processed' },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const runs = await new CodexNativeTraceReader(teamsBasePath).readTaskRuns({
|
||||
teamName,
|
||||
taskIds: [taskId, '8421e1bb'],
|
||||
includeIncoming: true,
|
||||
});
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.partial).toBe(false);
|
||||
expect(runs[0]?.events[0]?.projection).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
toolName: 'Bash',
|
||||
result: { content: 'processed' },
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to raw Codex command/file events and ignores malformed trailing incoming line', async () => {
|
||||
const teamName = 'vector-room-131313';
|
||||
const taskId = '891e1f68-d5b0-40f7-aa48-c378607e0f3b';
|
||||
const dir = path.join(
|
||||
teamsBasePath,
|
||||
TRACE_ROOT_SEGMENT,
|
||||
'incoming',
|
||||
traceSegment(teamName),
|
||||
traceSegment(taskId)
|
||||
);
|
||||
await mkdir(dir, { recursive: true });
|
||||
await writeFile(
|
||||
path.join(dir, 'run-raw.jsonl.tmp'),
|
||||
[
|
||||
JSON.stringify(header({ runId: 'run-raw', taskId, ownerName: 'jack' })),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_stdout_event',
|
||||
receivedAt: '2026-05-01T17:19:36.000Z',
|
||||
sourceOrder: 1,
|
||||
raw: {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_1',
|
||||
type: 'command_execution',
|
||||
command: 'pwd',
|
||||
aggregated_output: '/repo\n',
|
||||
exit_code: 2,
|
||||
status: 'completed',
|
||||
},
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_stdout_event',
|
||||
receivedAt: '2026-05-01T17:19:37.000Z',
|
||||
sourceOrder: 2,
|
||||
raw: {
|
||||
type: 'item.completed',
|
||||
item: {
|
||||
id: 'item_2',
|
||||
type: 'file_change',
|
||||
changes: [{ path: '/repo/src/a.ts', kind: 'update' }],
|
||||
status: 'completed',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'{"schemaVersion":1,"recordType":"codex_native_stdout_event"',
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const runs = await new CodexNativeTraceReader(teamsBasePath).readTaskRuns({
|
||||
teamName,
|
||||
taskIds: [taskId, '891e1f68'],
|
||||
includeIncoming: true,
|
||||
});
|
||||
|
||||
expect(runs).toHaveLength(1);
|
||||
expect(runs[0]?.partial).toBe(true);
|
||||
expect(runs[0]?.events.map((event) => event.projection?.toolName)).toEqual(['Bash', 'Edit']);
|
||||
expect(runs[0]?.events[0]?.projection).toMatchObject({
|
||||
kind: 'tool_result',
|
||||
rawItemType: 'command_execution',
|
||||
result: {
|
||||
content: '/repo\n',
|
||||
stdout: '/repo\n',
|
||||
exitCode: 2,
|
||||
},
|
||||
isError: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects trace files whose header belongs to another team or task', async () => {
|
||||
const teamName = 'vector-room-131313';
|
||||
const taskId = '8421e1bb-2f3b-4656-9983-6e0fd4b15963';
|
||||
await writeTraceFile({
|
||||
bucket: 'processed',
|
||||
teamName,
|
||||
taskId,
|
||||
runId: 'run-wrong-team',
|
||||
records: [
|
||||
header({ teamName: 'another-team', runId: 'run-wrong-team' }),
|
||||
{
|
||||
schemaVersion: 1,
|
||||
recordType: 'codex_native_stdout_event',
|
||||
receivedAt: '2026-05-01T17:10:08.000Z',
|
||||
sourceOrder: 1,
|
||||
projection: {
|
||||
kind: 'tool_result',
|
||||
toolSource: 'native',
|
||||
itemId: 'item_1',
|
||||
toolName: 'Bash',
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await expect(
|
||||
new CodexNativeTraceReader(teamsBasePath).readTaskRuns({
|
||||
teamName,
|
||||
taskIds: [taskId],
|
||||
})
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
});
|
||||
99
test/main/services/team/HistoricalBoardMcpRawProbe.test.ts
Normal file
99
test/main/services/team/HistoricalBoardMcpRawProbe.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { mkdtemp, rm, writeFile } from 'fs/promises';
|
||||
import { tmpdir } from 'os';
|
||||
import path from 'path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { HistoricalBoardMcpRawProbe } from '../../../../src/main/services/team/taskLogs/stream/HistoricalBoardMcpRawProbe';
|
||||
|
||||
import type { TeamTask } from '../../../../src/shared/types';
|
||||
|
||||
function makeTask(): TeamTask {
|
||||
return {
|
||||
id: '11111111-2222-3333-4444-555555555555',
|
||||
displayId: 'abcd1234',
|
||||
subject: 'Test task',
|
||||
status: 'in_progress',
|
||||
};
|
||||
}
|
||||
|
||||
describe('HistoricalBoardMcpRawProbe', () => {
|
||||
let tempDir: string | null = null;
|
||||
|
||||
afterEach(async () => {
|
||||
if (tempDir) {
|
||||
await rm(tempDir, { recursive: true, force: true });
|
||||
tempDir = null;
|
||||
}
|
||||
});
|
||||
|
||||
it('returns only files that contain both a task reference and board MCP marker', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-'));
|
||||
const hitFile = path.join(tempDir, 'hit.jsonl');
|
||||
const taskOnlyFile = path.join(tempDir, 'task-only.jsonl');
|
||||
const markerOnlyFile = path.join(tempDir, 'marker-only.jsonl');
|
||||
|
||||
await writeFile(
|
||||
hitFile,
|
||||
JSON.stringify({
|
||||
message: {
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
name: 'mcp__agent-teams__task_add_comment',
|
||||
input: { taskId: '#abcd1234' },
|
||||
},
|
||||
],
|
||||
},
|
||||
}) + '\n',
|
||||
'utf8'
|
||||
);
|
||||
await writeFile(taskOnlyFile, 'the task #abcd1234 is mentioned without a tool\n', 'utf8');
|
||||
await writeFile(markerOnlyFile, 'mcp__agent-teams__task_add_comment unrelated\n', 'utf8');
|
||||
|
||||
const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({
|
||||
task: makeTask(),
|
||||
transcriptFiles: [markerOnlyFile, hitFile, taskOnlyFile],
|
||||
});
|
||||
|
||||
expect(result.filePaths).toEqual([hitFile]);
|
||||
expect(result.scannedFileCount).toBe(3);
|
||||
expect(result.hitCount).toBe(1);
|
||||
});
|
||||
|
||||
it('matches canonical task ids as well as display ids', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-'));
|
||||
const hitFile = path.join(tempDir, 'hit-canonical.jsonl');
|
||||
await writeFile(
|
||||
hitFile,
|
||||
'agent_teams_task_complete ' + '11111111-2222-3333-4444-555555555555\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({
|
||||
task: makeTask(),
|
||||
transcriptFiles: [hitFile],
|
||||
});
|
||||
|
||||
expect(result.filePaths).toEqual([hitFile]);
|
||||
});
|
||||
|
||||
it('does not match task subject text without task id or display id evidence', async () => {
|
||||
tempDir = await mkdtemp(path.join(tmpdir(), 'historical-board-raw-probe-'));
|
||||
const subjectOnlyFile = path.join(tempDir, 'subject-only.jsonl');
|
||||
await writeFile(
|
||||
subjectOnlyFile,
|
||||
'mcp__agent-teams__task_add_comment mentions only Test task subject text\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const result = await new HistoricalBoardMcpRawProbe().findCandidateFiles({
|
||||
task: makeTask(),
|
||||
transcriptFiles: [subjectOnlyFile],
|
||||
});
|
||||
|
||||
expect(result.filePaths).toEqual([]);
|
||||
expect(result.scannedFileCount).toBe(1);
|
||||
expect(result.hitCount).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,168 @@
|
|||
import path from 'path';
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { TaskLogTranscriptCandidateSelector } from '../../../../src/main/services/team/taskLogs/stream/TaskLogTranscriptCandidateSelector';
|
||||
|
||||
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
|
||||
|
||||
function makeRecord(args: {
|
||||
id: string;
|
||||
filePath: string;
|
||||
sessionId?: string;
|
||||
canonicalToolName?: string;
|
||||
category?: NonNullable<BoardTaskActivityRecord['action']>['category'];
|
||||
}): BoardTaskActivityRecord {
|
||||
return {
|
||||
id: args.id,
|
||||
timestamp: '2026-04-30T10:00:00.000Z',
|
||||
task: {
|
||||
locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' },
|
||||
resolution: 'resolved',
|
||||
},
|
||||
linkKind: 'board_action',
|
||||
targetRole: 'subject',
|
||||
actor: {
|
||||
memberName: 'alice',
|
||||
role: 'member',
|
||||
sessionId: args.sessionId ?? '',
|
||||
isSidechain: true,
|
||||
},
|
||||
actorContext: { relation: 'same_task' },
|
||||
...(args.canonicalToolName || args.category
|
||||
? {
|
||||
action: {
|
||||
canonicalToolName: args.canonicalToolName ?? 'task_add_comment',
|
||||
toolUseId: `${args.id}-tool`,
|
||||
category: args.category ?? 'comment',
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
source: {
|
||||
filePath: args.filePath,
|
||||
messageUuid: `${args.id}-msg`,
|
||||
toolUseId: `${args.id}-tool`,
|
||||
sourceOrder: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('TaskLogTranscriptCandidateSelector', () => {
|
||||
it('selects direct record files and same-session files for non-read task records', () => {
|
||||
const projectDir = path.join('/tmp', 'claude-project');
|
||||
const rootFile = path.join(projectDir, 'session-a.jsonl');
|
||||
const subagentFile = path.join(projectDir, 'session-a', 'subagents', 'agent-worker.jsonl');
|
||||
const unrelatedFile = path.join(projectDir, 'session-b.jsonl');
|
||||
const selector = new TaskLogTranscriptCandidateSelector();
|
||||
|
||||
const selection = selector.selectInferredNativeTranscriptFiles({
|
||||
projectDir,
|
||||
transcriptFiles: [unrelatedFile, subagentFile, rootFile],
|
||||
records: [
|
||||
makeRecord({
|
||||
id: 'comment',
|
||||
filePath: rootFile,
|
||||
sessionId: 'session-a',
|
||||
canonicalToolName: 'task_add_comment',
|
||||
category: 'comment',
|
||||
}),
|
||||
],
|
||||
alreadyParsedFilePaths: new Set([rootFile]),
|
||||
});
|
||||
|
||||
expect(selection.filePaths).toEqual([subagentFile]);
|
||||
expect(selection.candidates.map((candidate) => candidate.filePath)).toEqual([
|
||||
rootFile,
|
||||
subagentFile,
|
||||
]);
|
||||
expect(selection.diagnostics).toMatchObject({
|
||||
recordFileCount: 1,
|
||||
nonReadSessionCount: 1,
|
||||
sameSessionFileCount: 2,
|
||||
alreadyParsedCandidateCount: 1,
|
||||
finalCandidateCount: 1,
|
||||
reason: 'same_session_native_window',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not expand read-only task records to every file in the same session', () => {
|
||||
const projectDir = path.join('/tmp', 'claude-project');
|
||||
const rootFile = path.join(projectDir, 'session-a.jsonl');
|
||||
const subagentFile = path.join(projectDir, 'session-a', 'subagents', 'agent-worker.jsonl');
|
||||
const selector = new TaskLogTranscriptCandidateSelector();
|
||||
|
||||
const selection = selector.selectInferredNativeTranscriptFiles({
|
||||
projectDir,
|
||||
transcriptFiles: [rootFile, subagentFile],
|
||||
records: [
|
||||
makeRecord({
|
||||
id: 'read',
|
||||
filePath: rootFile,
|
||||
sessionId: 'session-a',
|
||||
canonicalToolName: 'task_get',
|
||||
category: 'read',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(selection.filePaths).toEqual([rootFile]);
|
||||
expect(selection.candidates.map((candidate) => candidate.filePath)).toEqual([rootFile]);
|
||||
expect(selection.diagnostics).toMatchObject({
|
||||
nonReadSessionCount: 0,
|
||||
sameSessionFileCount: 0,
|
||||
reason: 'direct_record_files',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to direct record files when the transcript file cannot be session-indexed', () => {
|
||||
const projectDir = path.join('/tmp', 'claude-project');
|
||||
const outsideFile = path.join('/tmp', 'other-project', 'session-a.jsonl');
|
||||
const selector = new TaskLogTranscriptCandidateSelector();
|
||||
|
||||
const selection = selector.selectInferredNativeTranscriptFiles({
|
||||
projectDir,
|
||||
transcriptFiles: [outsideFile],
|
||||
records: [
|
||||
makeRecord({
|
||||
id: 'comment',
|
||||
filePath: outsideFile,
|
||||
canonicalToolName: 'task_add_comment',
|
||||
category: 'comment',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(selection.filePaths).toEqual([outsideFile]);
|
||||
expect(selection.diagnostics).toMatchObject({
|
||||
recordFileCount: 1,
|
||||
nonReadSessionCount: 0,
|
||||
sameSessionFileCount: 0,
|
||||
finalCandidateCount: 1,
|
||||
reason: 'direct_record_files',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not select files by owner-looking names without session evidence', () => {
|
||||
const projectDir = path.join('/tmp', 'claude-project');
|
||||
const recordFile = path.join(projectDir, 'session-a.jsonl');
|
||||
const ownerLookingFile = path.join(projectDir, 'alice-work.jsonl');
|
||||
const selector = new TaskLogTranscriptCandidateSelector();
|
||||
|
||||
const selection = selector.selectInferredNativeTranscriptFiles({
|
||||
projectDir,
|
||||
transcriptFiles: [recordFile, ownerLookingFile],
|
||||
records: [
|
||||
makeRecord({
|
||||
id: 'comment',
|
||||
filePath: recordFile,
|
||||
sessionId: undefined,
|
||||
canonicalToolName: 'task_add_comment',
|
||||
category: 'comment',
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
expect(selection.filePaths).toEqual([recordFile]);
|
||||
expect(selection.filePaths).not.toContain(ownerLookingFile);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue