feat(task-logs): show codex native trace fallback

This commit is contained in:
777genius 2026-05-01 22:39:31 +03:00
parent ca21ab206e
commit 90aa2942f9
22 changed files with 2991 additions and 94 deletions

View file

@ -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)) ?? [];
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View 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,
};
}
}

View file

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

View file

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

View file

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

View file

@ -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)]" />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View 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();
});
});

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

View 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([]);
});
});

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

View file

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