feat(task-logs): add board task activity and task log stream

This commit is contained in:
777genius 2026-04-12 22:13:43 +03:00
parent 57c384531a
commit 32cea2a927
68 changed files with 14114 additions and 74 deletions

View file

@ -10,10 +10,11 @@
- [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md)
- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md)
- [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md)
- [Iteration 07 - Task Logs + Explicit Board Task Links](./iteration-07-task-logs-explicit-board-task-links.md)
- [Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer](./iteration-08-exact-task-logs-reuse-existing-renderer.md)
## Принципы
- **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI)
- **Чёткий scope**: у каждой итерации есть цели и не‑цели
- **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,192 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://claude-team.local/schemas/board-task-transcript-v1.schema.json",
"title": "Board Task Transcript V1",
"type": "object",
"properties": {
"uuid": {
"type": "string",
"minLength": 1
},
"timestamp": {
"type": "string",
"minLength": 1
},
"sessionId": {
"type": "string",
"minLength": 1
},
"boardTaskLinks": {
"type": "array",
"items": {
"$ref": "#/$defs/boardTaskLink"
}
},
"boardTaskToolActions": {
"type": "array",
"items": {
"$ref": "#/$defs/boardTaskToolAction"
}
}
},
"$defs": {
"boardTaskLocator": {
"type": "object",
"required": ["ref", "refKind"],
"properties": {
"ref": {
"type": "string",
"minLength": 1
},
"refKind": {
"type": "string",
"enum": ["canonical", "display", "unknown"]
},
"canonicalId": {
"type": "string",
"minLength": 1
}
},
"additionalProperties": false
},
"actorContext": {
"type": "object",
"required": ["relation"],
"properties": {
"relation": {
"type": "string",
"enum": ["same_task", "other_active_task", "idle", "ambiguous"]
},
"activeTask": {
"$ref": "#/$defs/boardTaskLocator"
},
"activePhase": {
"type": "string",
"enum": ["work", "review"]
},
"activeExecutionSeq": {
"type": "number"
}
},
"allOf": [
{
"if": {
"properties": {
"relation": {
"enum": ["same_task", "idle", "ambiguous"]
}
},
"required": ["relation"]
},
"then": {
"not": {
"anyOf": [
{ "required": ["activeTask"] },
{ "required": ["activePhase"] },
{ "required": ["activeExecutionSeq"] }
]
}
}
}
],
"additionalProperties": false
},
"boardTaskLink": {
"type": "object",
"required": ["schemaVersion", "task", "targetRole", "linkKind", "actorContext"],
"properties": {
"schemaVersion": {
"const": 1
},
"toolUseId": {
"type": "string",
"minLength": 1
},
"task": {
"$ref": "#/$defs/boardTaskLocator"
},
"targetRole": {
"type": "string",
"enum": ["subject", "related"]
},
"linkKind": {
"type": "string",
"enum": ["execution", "lifecycle", "board_action"]
},
"taskArgumentSlot": {
"type": "string",
"enum": ["taskId", "targetId"]
},
"actorContext": {
"$ref": "#/$defs/actorContext"
}
},
"allOf": [
{
"if": {
"properties": {
"linkKind": {
"const": "execution"
}
},
"required": ["linkKind"]
},
"then": {
"not": {
"anyOf": [
{ "required": ["taskArgumentSlot"] }
]
}
}
}
],
"additionalProperties": false
},
"boardTaskToolAction": {
"type": "object",
"required": ["schemaVersion", "toolUseId", "canonicalToolName"],
"properties": {
"schemaVersion": {
"const": 1
},
"toolUseId": {
"type": "string",
"minLength": 1
},
"canonicalToolName": {
"type": "string",
"minLength": 1
},
"input": {
"type": "object",
"properties": {
"status": {
"type": "string",
"enum": ["pending", "in_progress", "completed", "deleted"]
},
"owner": { "type": ["string", "null"] },
"clarification": { "type": ["string", "null"], "enum": ["lead", "user", null] },
"reviewer": { "type": "string" },
"relationship": {
"type": "string",
"enum": ["blocked-by", "blocks", "related"]
},
"commentId": { "type": "string" }
},
"additionalProperties": false
},
"resultRefs": {
"type": "object",
"properties": {
"commentId": { "type": "string" },
"attachmentId": { "type": "string" },
"filename": { "type": "string" }
},
"additionalProperties": false
}
},
"additionalProperties": false
}
},
"additionalProperties": true
}

View file

@ -0,0 +1,92 @@
import { BoardTaskLogDiagnosticsService } from '../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
function usage(): string {
return 'Usage: pnpm exec tsx scripts/diagnose-task-log-stream.ts <team-name> <task-id-or-display-id> [--json]';
}
function formatExamples(
title: string,
examples: Array<{
timestamp: string;
toolName: string;
toolUseId?: string;
filePath: string;
messageUuid: string;
isSidechain: boolean;
agentId?: string;
}>,
): string[] {
if (examples.length === 0) {
return [];
}
return [
title,
...examples.map((example) => {
const parts = [
`- ${example.timestamp}`,
example.toolName,
`message=${example.messageUuid}`,
`file=${example.filePath}`,
`sidechain=${String(example.isSidechain)}`,
];
if (example.toolUseId) {
parts.push(`toolUseId=${example.toolUseId}`);
}
if (example.agentId) {
parts.push(`agentId=${example.agentId}`);
}
return parts.join(' ');
}),
];
}
async function main(): Promise<void> {
const teamName = process.argv[2];
const taskRef = process.argv[3];
const jsonMode = process.argv.includes('--json');
if (!teamName || !taskRef) {
console.error(usage());
process.exitCode = 1;
return;
}
const diagnosticsService = new BoardTaskLogDiagnosticsService();
const report = await diagnosticsService.diagnose(teamName, taskRef);
if (jsonMode) {
console.log(JSON.stringify(report, null, 2));
return;
}
const lines = [
`Task log diagnostics for ${report.teamName} #${report.task.displayId}`,
`Task: ${report.task.subject}`,
`Status: ${report.task.status}${report.task.owner ? ` owner=${report.task.owner}` : ''}`,
`Transcript files: ${report.transcript.fileCount}`,
`Explicit records: total=${report.explicitRecords.total} execution=${report.explicitRecords.execution} lifecycle=${report.explicitRecords.lifecycle} boardAction=${report.explicitRecords.boardAction}`,
`Explicit participants: ${report.explicitRecords.participants.join(', ') || 'none'}`,
`Explicit tool names: ${report.explicitRecords.toolNames.join(', ') || 'none'}`,
`Interval tool results: total=${report.intervalToolResults.total} boardMcp=${report.intervalToolResults.boardMcp} worker=${report.intervalToolResults.worker.total} explicitWorker=${report.intervalToolResults.worker.explicitLinked} missingWorker=${report.intervalToolResults.worker.missingExplicit}`,
`Stream: participants=${report.stream.participants.join(', ') || 'none'} defaultFilter=${report.stream.defaultFilter} segments=${report.stream.segmentCount}`,
`Visible stream tools: ${report.stream.visibleToolNames.join(', ') || 'none'}`,
'Diagnosis:',
...report.diagnosis.map((line) => `- ${line}`),
...formatExamples(
'Missing worker tool results without explicit links:',
report.intervalToolResults.worker.examples,
),
...formatExamples(
'Empty payload examples from current stream:',
report.stream.emptyPayloadExamples,
),
];
console.log(lines.join('\n'));
}
main().catch((error) => {
console.error(String(error));
process.exitCode = 1;
});

View file

@ -90,6 +90,10 @@ import { registerWindowHandlers, removeWindowHandlers } from './window';
import type {
BranchStatusService,
BoardTaskActivityService,
BoardTaskLogStreamService,
BoardTaskExactLogDetailService,
BoardTaskExactLogsService,
ChangeExtractorService,
CliInstallerService,
FileContentResolver,
@ -130,6 +134,10 @@ export function initializeIpcHandlers(
teamProvisioningService: TeamProvisioningService,
teamMemberLogsFinder: TeamMemberLogsFinder,
memberStatsComputer: MemberStatsComputer,
boardTaskActivityService: BoardTaskActivityService,
boardTaskLogStreamService: BoardTaskLogStreamService,
boardTaskExactLogsService: BoardTaskExactLogsService,
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
teammateToolTracker: TeammateToolTracker | undefined,
branchStatusService: BranchStatusService | undefined,
contextCallbacks: {
@ -174,7 +182,11 @@ export function initializeIpcHandlers(
memberStatsComputer,
teamBackupService,
teammateToolTracker,
branchStatusService
branchStatusService,
boardTaskActivityService,
boardTaskLogStreamService,
boardTaskExactLogsService,
boardTaskExactLogDetailService
);
initializeConfigHandlers({
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,

View file

@ -21,10 +21,14 @@ import {
TEAM_GET_CLAUDE_LOGS,
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_MESSAGES_PAGE,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_TASK_ACTIVITY,
TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
TEAM_GET_PROJECT_BRANCH,
TEAM_GET_SAVED_REQUEST,
TEAM_GET_TASK_ATTACHMENT,
@ -98,15 +102,15 @@ import {
buildActionModeAgentBlock,
isAgentActionMode,
} from '../services/team/actionModeInstructions';
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '../services/team/memberUpdateNotifications';
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
import { TeamMetaStore } from '../services/team/TeamMetaStore';
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
import {
buildReplaceMembersDiff,
buildReplaceMembersSummaryMessage,
} from '../services/team/memberUpdateNotifications';
import {
validateFromField,
@ -118,6 +122,10 @@ import {
import type {
BranchStatusService,
BoardTaskActivityService,
BoardTaskLogStreamService,
BoardTaskExactLogDetailService,
BoardTaskExactLogsService,
MemberStatsComputer,
TeamDataService,
TeammateToolTracker,
@ -131,6 +139,10 @@ import type {
AttachmentFileData,
AttachmentMeta,
AttachmentPayload,
BoardTaskActivityEntry,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
CreateTaskRequest,
EffortLevel,
GlobalTask,
@ -143,6 +155,7 @@ import type {
MemberLogSummary,
MemberSpawnStatusEntry,
MemberSpawnStatusesSnapshot,
MessagesPage,
SendMessageRequest,
SendMessageResult,
TaskAttachmentMeta,
@ -155,7 +168,6 @@ import type {
TeamCreateRequest,
TeamCreateResponse,
TeamData,
MessagesPage,
TeamLaunchRequest,
TeamLaunchResponse,
TeamMessageNotificationData,
@ -184,7 +196,7 @@ const SEEN_RATE_LIMIT_KEYS_MAX = 500;
async function getDurableLeadTeammateRoster(
teamName: string,
leadName: string
): Promise<Array<{ name: string; role?: string }>> {
): Promise<{ name: string; role?: string }[]> {
const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? '';
const leadLower = normalize(leadName);
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
@ -241,7 +253,7 @@ async function getDurableLeadTeammateRoster(
function buildLeadRosterContextBlock(
teamName: string,
leadName: string,
teammates: Array<{ name: string; role?: string }>
teammates: { name: string; role?: string }[]
): string | null {
if (teammates.length === 0) return null;
@ -377,6 +389,10 @@ let memberStatsComputer: MemberStatsComputer | null = null;
let teamBackupService: TeamBackupService | null = null;
let teammateToolTracker: TeammateToolTracker | null = null;
let branchStatusService: BranchStatusService | null = null;
let boardTaskActivityService: BoardTaskActivityService | null = null;
let boardTaskLogStreamService: BoardTaskLogStreamService | null = null;
let boardTaskExactLogsService: BoardTaskExactLogsService | null = null;
let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null;
const attachmentStore = new TeamAttachmentStore();
const taskAttachmentStore = new TeamTaskAttachmentStore();
@ -407,7 +423,11 @@ export function initializeTeamHandlers(
statsComputer?: MemberStatsComputer,
backupService?: TeamBackupService,
toolTracker?: TeammateToolTracker,
branchTracker?: BranchStatusService
branchTracker?: BranchStatusService,
taskActivityService?: BoardTaskActivityService,
taskLogStreamService?: BoardTaskLogStreamService,
taskExactLogsService?: BoardTaskExactLogsService,
taskExactLogDetailService?: BoardTaskExactLogDetailService
): void {
teamDataService = service;
teamProvisioningService = provisioningService;
@ -416,6 +436,10 @@ export function initializeTeamHandlers(
teamBackupService = backupService ?? null;
teammateToolTracker = toolTracker ?? null;
branchStatusService = branchTracker ?? null;
boardTaskActivityService = taskActivityService ?? null;
boardTaskLogStreamService = taskLogStreamService ?? null;
boardTaskExactLogsService = taskExactLogsService ?? null;
boardTaskExactLogDetailService = taskExactLogDetailService ?? null;
}
export function registerTeamHandlers(ipcMain: IpcMain): void {
@ -450,6 +474,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig);
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity);
ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream);
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries);
ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail);
ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats);
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
ipcMain.handle(TEAM_START_TASK, handleStartTask);
@ -517,6 +545,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY);
ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM);
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL);
ipcMain.removeHandler(TEAM_GET_MEMBER_STATS);
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
ipcMain.removeHandler(TEAM_START_TASK);
@ -579,6 +611,34 @@ function getBranchStatusService(): BranchStatusService {
return branchStatusService;
}
function getBoardTaskActivityService(): BoardTaskActivityService {
if (!boardTaskActivityService) {
throw new Error('Board task activity service is not initialized');
}
return boardTaskActivityService;
}
function getBoardTaskLogStreamService(): BoardTaskLogStreamService {
if (!boardTaskLogStreamService) {
throw new Error('Board task log stream service is not initialized');
}
return boardTaskLogStreamService;
}
function getBoardTaskExactLogsService(): BoardTaskExactLogsService {
if (!boardTaskExactLogsService) {
throw new Error('Board task exact logs service is not initialized');
}
return boardTaskExactLogsService;
}
function getBoardTaskExactLogDetailService(): BoardTaskExactLogDetailService {
if (!boardTaskExactLogDetailService) {
throw new Error('Board task exact log detail service is not initialized');
}
return boardTaskExactLogDetailService;
}
async function wrapTeamHandler<T>(
operation: string,
handler: () => Promise<T>
@ -1371,7 +1431,7 @@ async function handlePrepareProvisioning(
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
let validatedCwd: string | undefined;
let validatedProviderId: TeamLaunchRequest['providerId'];
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
if (cwd !== undefined) {
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
return { success: false, error: 'cwd must be a non-empty string' };
@ -1391,7 +1451,7 @@ async function handlePrepareProvisioning(
if (!Array.isArray(providerIds)) {
return { success: false, error: 'providerIds must be an array when provided' };
}
const normalized: Array<'anthropic' | 'codex' | 'gemini'> = [];
const normalized: ('anthropic' | 'codex' | 'gemini')[] = [];
for (const entry of providerIds) {
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' };
@ -2440,6 +2500,94 @@ async function handleGetLogsForTask(
);
}
async function handleGetTaskActivity(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<BoardTaskActivityEntry[]>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('getTaskActivity', () =>
getBoardTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!)
);
}
async function handleGetTaskLogStream(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<BoardTaskLogStreamResponse>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('getTaskLogStream', () =>
getBoardTaskLogStreamService().getTaskLogStream(vTeam.value!, vTask.value!)
);
}
async function handleGetTaskExactLogSummaries(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown
): Promise<IpcResult<BoardTaskExactLogSummariesResponse>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
return wrapTeamHandler('getTaskExactLogSummaries', () =>
getBoardTaskExactLogsService().getTaskExactLogSummaries(vTeam.value!, vTask.value!)
);
}
async function handleGetTaskExactLogDetail(
_event: IpcMainInvokeEvent,
teamName: unknown,
taskId: unknown,
exactLogId: unknown,
expectedSourceGeneration: unknown
): Promise<IpcResult<BoardTaskExactLogDetailResult>> {
const vTeam = validateTeamName(teamName);
if (!vTeam.valid) {
return { success: false, error: vTeam.error ?? 'Invalid teamName' };
}
const vTask = validateTaskId(taskId);
if (!vTask.valid) {
return { success: false, error: vTask.error ?? 'Invalid taskId' };
}
if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) {
return { success: false, error: 'exactLogId must be a non-empty string' };
}
if (
typeof expectedSourceGeneration !== 'string' ||
expectedSourceGeneration.trim().length === 0
) {
return { success: false, error: 'expectedSourceGeneration must be a non-empty string' };
}
return wrapTeamHandler('getTaskExactLogDetail', () =>
getBoardTaskExactLogDetailService().getTaskExactLogDetail(
vTeam.value!,
vTask.value!,
exactLogId.trim(),
expectedSourceGeneration.trim()
)
);
}
function getMemberStatsComputer(): MemberStatsComputer {
if (!memberStatsComputer) {
throw new Error('Member stats computer is not initialized');

View file

@ -3,6 +3,11 @@ import { createReadStream } from 'fs';
import { stat } from 'fs/promises';
import * as readline from 'readline';
import {
canonicalizeAgentTeamsToolName,
isAgentTeamsTaskBoundaryToolName,
} from './agentTeamsToolNames';
import type {
TaskBoundariesResult,
TaskBoundary,
@ -31,8 +36,6 @@ interface ToolUseInfo {
filePath?: string;
}
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
function extractTaskId(input: Record<string, unknown>): string {
@ -102,7 +105,7 @@ export class TaskBoundaryParser {
const b = block as Record<string, unknown>;
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
const toolName = canonicalizeAgentTeamsToolName(rawName);
const toolUseId = typeof b.id === 'string' ? b.id : '';
const input = b.input as Record<string, unknown> | undefined;
const fp = typeof input?.file_path === 'string' ? input.file_path : undefined;
@ -238,8 +241,8 @@ export class TaskBoundaryParser {
if (b.type !== 'tool_use') continue;
const rawName = typeof b.name === 'string' ? b.name : '';
const toolName = rawName.replace(/^proxy_/, '');
if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue;
const toolName = canonicalizeAgentTeamsToolName(rawName);
if (!isAgentTeamsTaskBoundaryToolName(toolName)) continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;

View file

@ -10,6 +10,10 @@ import * as readline from 'readline';
import { TeamConfigReader } from './TeamConfigReader';
import { TeamInboxReader } from './TeamInboxReader';
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
import {
canonicalizeAgentTeamsToolName,
lineHasAgentTeamsTaskBoundaryToolName,
} from './agentTeamsToolNames';
import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
@ -684,7 +688,7 @@ export class TeamMemberLogsFinder {
async listAttributedSubagentFiles(
teamName: string
): Promise<Array<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }>> {
): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> {
const discovery = await this.discoverProjectSessions(teamName);
if (!discovery) return [];
@ -700,12 +704,12 @@ export class TeamMemberLogsFinder {
? [currentLeadSessionId]
: sessionIds;
const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds);
const results: Array<{
const results: {
memberName: string;
sessionId: string;
filePath: string;
mtimeMs: number;
}> = [];
}[] = [];
const settled = await Promise.all(
candidates.map(async (candidate) => {
@ -764,12 +768,7 @@ export class TeamMemberLogsFinder {
stream.destroy();
return true;
}
if (
(line.includes('"task_start"') ||
line.includes('"task_complete"') ||
line.includes('"task_set_status"')) &&
pattern.test(line)
) {
if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) {
rl.close();
stream.destroy();
return true;
@ -1146,13 +1145,9 @@ export class TeamMemberLogsFinder {
// Skip read-only task tools — they reference taskId but don't indicate
// that this session actually WORKED on the task. Agents commonly call
// task_get to check dependencies from other tasks, producing false matches.
const toolName = typeof b.name === 'string' ? b.name : '';
if (
toolName === 'task_get' ||
toolName === 'mcp__agent-teams__task_get' ||
toolName === 'TaskGet'
)
continue;
const rawToolName = typeof b.name === 'string' ? b.name : '';
const toolName = canonicalizeAgentTeamsToolName(rawToolName);
if (toolName === 'task_get' || toolName === 'TaskGet') continue;
const input = b.input as Record<string, unknown> | undefined;
if (!input) continue;

View file

@ -0,0 +1,43 @@
const AGENT_TEAMS_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
const TASK_BOUNDARY_TOOL_NAMES = ['task_start', 'task_complete', 'task_set_status'] as const;
const TASK_BOUNDARY_TOOL_SET = new Set<string>(TASK_BOUNDARY_TOOL_NAMES);
function escapeRegex(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
const TASK_BOUNDARY_TOOL_LINE_PATTERN = new RegExp(
`"name"\\s*:\\s*"(?:${[
...TASK_BOUNDARY_TOOL_NAMES,
...TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${toolName}`),
...AGENT_TEAMS_PREFIXES.flatMap((prefix) =>
TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `${prefix}${toolName}`)
),
...AGENT_TEAMS_PREFIXES.flatMap((prefix) =>
TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${prefix}${toolName}`)
),
]
.map(escapeRegex)
.join('|')})"`
);
export function canonicalizeAgentTeamsToolName(rawName: string): string {
const normalized = rawName.replace(/^proxy_/, '');
for (const prefix of AGENT_TEAMS_PREFIXES) {
if (normalized.startsWith(prefix)) {
return normalized.slice(prefix.length);
}
}
return normalized;
}
export function isAgentTeamsTaskBoundaryToolName(rawName: string): boolean {
return TASK_BOUNDARY_TOOL_SET.has(canonicalizeAgentTeamsToolName(rawName));
}
export function lineHasAgentTeamsTaskBoundaryToolName(line: string): boolean {
return TASK_BOUNDARY_TOOL_LINE_PATTERN.test(line);
}

View file

@ -1,4 +1,9 @@
export { BranchStatusService } from './BranchStatusService';
export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource';
export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService';
export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService';
export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService';
export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService';
export { CascadeGuard } from './CascadeGuard';
export { ChangeExtractorService } from './ChangeExtractorService';
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';

View file

@ -0,0 +1,83 @@
import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder';
import type { BoardTaskActivityEntry, TeamTask } from '@shared/types';
import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader';
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
function cloneTaskRef(task: BoardTaskActivityRecord['task']): BoardTaskActivityEntry['task'] {
return {
locator: { ...task.locator },
resolution: task.resolution,
...(task.taskRef ? { taskRef: { ...task.taskRef } } : {}),
};
}
function cloneActorContext(
actorContext: BoardTaskActivityRecord['actorContext']
): BoardTaskActivityEntry['actorContext'] {
return {
relation: actorContext.relation,
...(actorContext.activeTask ? { activeTask: cloneTaskRef(actorContext.activeTask) } : {}),
...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}),
...(actorContext.activeExecutionSeq
? { activeExecutionSeq: actorContext.activeExecutionSeq }
: {}),
};
}
function cloneAction(
action: BoardTaskActivityRecord['action']
): BoardTaskActivityEntry['action'] | undefined {
if (!action) return undefined;
return {
...(action.canonicalToolName ? { canonicalToolName: action.canonicalToolName } : {}),
...(action.toolUseId ? { toolUseId: action.toolUseId } : {}),
category: action.category,
...(action.peerTask ? { peerTask: cloneTaskRef(action.peerTask) } : {}),
...(action.relationshipPerspective
? { relationshipPerspective: action.relationshipPerspective }
: {}),
...(action.details ? { details: { ...action.details } } : {}),
};
}
export class BoardTaskActivityEntryBuilder {
constructor(
private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder()
) {}
buildForTask(args: {
teamName: string;
targetTask: TeamTask;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): BoardTaskActivityEntry[] {
return this.buildFromRecords(this.recordBuilder.buildForTask(args));
}
buildFromRecords(records: BoardTaskActivityRecord[]): BoardTaskActivityEntry[] {
return records.map((record) => ({
id: record.id,
timestamp: record.timestamp,
task: cloneTaskRef(record.task),
linkKind: record.linkKind,
targetRole: record.targetRole,
actor: {
...(record.actor.memberName ? { memberName: record.actor.memberName } : {}),
role: record.actor.role,
sessionId: record.actor.sessionId,
...(record.actor.agentId ? { agentId: record.actor.agentId } : {}),
isSidechain: record.actor.isSidechain,
},
actorContext: cloneActorContext(record.actorContext),
...(record.action ? { action: cloneAction(record.action) } : {}),
source: {
messageUuid: record.source.messageUuid,
filePath: record.source.filePath,
...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}),
sourceOrder: record.source.sourceOrder,
},
}));
}
}

View file

@ -0,0 +1,54 @@
interface CacheEntry<T> {
mtimeMs: number;
size: number;
value: T;
}
export class BoardTaskActivityParseCache<T> {
private readonly cache = new Map<string, CacheEntry<T>>();
private readonly inFlight = new Map<string, Promise<T>>();
getIfFresh(filePath: string, mtimeMs: number, size: number): T | null {
const cached = this.cache.get(filePath);
if (!cached) return null;
if (cached.mtimeMs !== mtimeMs || cached.size !== size) {
this.cache.delete(filePath);
return null;
}
return cached.value;
}
getInFlight(filePath: string): Promise<T> | null {
return this.inFlight.get(filePath) ?? null;
}
setInFlight(filePath: string, promise: Promise<T>): void {
this.inFlight.set(filePath, promise);
}
clearInFlight(filePath: string): void {
this.inFlight.delete(filePath);
}
set(filePath: string, mtimeMs: number, size: number, value: T): void {
this.cache.set(filePath, { mtimeMs, size, value });
}
clearForPath(filePath: string): void {
this.cache.delete(filePath);
this.inFlight.delete(filePath);
}
retainOnly(filePaths: Set<string>): void {
for (const filePath of this.cache.keys()) {
if (!filePaths.has(filePath)) {
this.cache.delete(filePath);
}
}
for (const filePath of this.inFlight.keys()) {
if (!filePaths.has(filePath)) {
this.inFlight.delete(filePath);
}
}
}
}

View file

@ -0,0 +1,25 @@
import type {
BoardTaskActivityAction,
BoardTaskActivityActor,
BoardTaskActivityActorContext,
BoardTaskActivityLinkKind,
BoardTaskActivityTargetRole,
BoardTaskActivityTaskRef,
} from '@shared/types';
export interface BoardTaskActivityRecord {
id: string;
timestamp: string;
task: BoardTaskActivityTaskRef;
linkKind: BoardTaskActivityLinkKind;
targetRole: BoardTaskActivityTargetRole;
actor: BoardTaskActivityActor;
actorContext: BoardTaskActivityActorContext;
action?: BoardTaskActivityAction;
source: {
messageUuid: string;
filePath: string;
toolUseId?: string;
sourceOrder: number;
};
}

View file

@ -0,0 +1,382 @@
import { createLogger } from '@shared/utils/logger';
import { getTaskDisplayId } from '@shared/utils/taskIdentity';
import type {
BoardTaskActivityAction,
BoardTaskActivityActor,
BoardTaskActivityCategory,
BoardTaskActivityTaskRef,
BoardTaskLocator,
TaskRef,
TeamTask,
} from '@shared/types';
import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader';
import type {
ParsedBoardTaskLink,
ParsedBoardTaskToolAction,
} from '../contract/BoardTaskTranscriptContract';
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
interface TaskLookup {
byId: Map<string, TeamTask>;
byDisplayId: Map<string, TeamTask[]>;
}
const logger = createLogger('Service:BoardTaskActivityRecordBuilder');
const CANONICAL_TASK_ID_PATTERN =
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
function noteReadDiagnostic(
event: string,
details: Record<string, string | number | undefined> = {}
): void {
const suffix = Object.entries(details)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`);
}
function buildTaskRef(teamName: string, task: TeamTask): TaskRef {
return {
taskId: task.id,
displayId: getTaskDisplayId(task),
teamName,
};
}
function normalizeDisplayRef(value: string): string {
return value.trim().toLowerCase();
}
function looksLikeCanonicalTaskId(value: string): boolean {
return CANONICAL_TASK_ID_PATTERN.test(value.trim());
}
function buildTaskLookup(tasks: TeamTask[]): TaskLookup {
const byId = new Map<string, TeamTask>();
const byDisplayId = new Map<string, TeamTask[]>();
for (const task of tasks) {
byId.set(task.id, task);
const displayId = normalizeDisplayRef(getTaskDisplayId(task));
const list = byDisplayId.get(displayId) ?? [];
list.push(task);
byDisplayId.set(displayId, list);
}
return { byId, byDisplayId };
}
function resolveLocatorToTaskRef(
teamName: string,
locator: BoardTaskLocator,
lookup: TaskLookup
): BoardTaskActivityTaskRef {
const canonicalCandidate =
(locator.canonicalId && lookup.byId.get(locator.canonicalId)) ||
(locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) ||
(locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref)
? lookup.byId.get(locator.ref)
: undefined);
if (canonicalCandidate) {
return {
locator,
resolution: canonicalCandidate.status === 'deleted' ? 'deleted' : 'resolved',
taskRef: buildTaskRef(teamName, canonicalCandidate),
};
}
const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? [];
if (displayCandidates.length === 1) {
const task = displayCandidates[0];
return {
locator,
resolution: task.status === 'deleted' ? 'deleted' : 'resolved',
taskRef: buildTaskRef(teamName, task),
};
}
if (displayCandidates.length > 1) {
noteReadDiagnostic('ambiguous_locator', { refKind: locator.refKind });
return {
locator,
resolution: 'ambiguous',
};
}
noteReadDiagnostic('unresolved_locator', { refKind: locator.refKind });
return {
locator,
resolution: 'unresolved',
};
}
function locatorCouldMatchTask(
locator: BoardTaskLocator,
targetTask: TeamTask,
lookup: TaskLookup
): boolean {
if (locator.canonicalId === targetTask.id) return true;
if (locator.refKind === 'canonical' && locator.ref === targetTask.id) return true;
const targetDisplayId = getTaskDisplayId(targetTask);
const normalizedLocatorRef = normalizeDisplayRef(locator.ref);
const normalizedTargetDisplayId = normalizeDisplayRef(targetDisplayId);
if (normalizedLocatorRef !== normalizedTargetDisplayId) return false;
const candidates = lookup.byDisplayId.get(normalizedTargetDisplayId) ?? [];
if (candidates.length === 0) return false;
return candidates.some((candidate) => candidate.id === targetTask.id);
}
function buildActionMap(
actions: ParsedBoardTaskToolAction[]
): Map<string, ParsedBoardTaskToolAction> {
const actionMap = new Map<string, ParsedBoardTaskToolAction>();
for (const action of actions) {
if (actionMap.has(action.toolUseId)) {
noteReadDiagnostic('duplicate_action_tool_use_id', { toolUseId: action.toolUseId });
continue;
}
actionMap.set(action.toolUseId, action);
}
return actionMap;
}
function buildActionCategory(action: ParsedBoardTaskToolAction): BoardTaskActivityCategory {
switch (action.canonicalToolName) {
case 'task_start':
case 'task_complete':
case 'task_set_status':
return 'status';
case 'review_start':
case 'review_request':
case 'review_approve':
case 'review_request_changes':
return 'review';
case 'task_add_comment':
case 'task_get_comment':
return 'comment';
case 'task_set_owner':
return 'assignment';
case 'task_get':
return 'read';
case 'task_attach_file':
case 'task_attach_comment_file':
return 'attachment';
case 'task_link':
case 'task_unlink':
return 'relationship';
case 'task_set_clarification':
return 'clarification';
default:
return 'other';
}
}
function buildActionDetails(
action: ParsedBoardTaskToolAction
): BoardTaskActivityAction['details'] | undefined {
const details = {
...(action.input?.status ? { status: action.input.status } : {}),
...(action.input && 'owner' in action.input ? { owner: action.input.owner } : {}),
...(action.input && 'clarification' in action.input
? { clarification: action.input.clarification }
: {}),
...(action.input?.reviewer ? { reviewer: action.input.reviewer } : {}),
...(action.input?.relationship ? { relationship: action.input.relationship } : {}),
...(action.input?.commentId ? { commentId: action.input.commentId } : {}),
...(action.resultRefs?.commentId ? { commentId: action.resultRefs.commentId } : {}),
...(action.resultRefs?.attachmentId ? { attachmentId: action.resultRefs.attachmentId } : {}),
...(action.resultRefs?.filename ? { filename: action.resultRefs.filename } : {}),
};
return Object.keys(details).length > 0 ? details : undefined;
}
function buildRelationshipPerspective(
link: ParsedBoardTaskLink,
action: ParsedBoardTaskToolAction
): BoardTaskActivityAction['relationshipPerspective'] | undefined {
const relationship = action.input?.relationship;
if (!relationship) {
return undefined;
}
if (relationship === 'related') {
return 'symmetric';
}
if (relationship === 'blocked-by') {
return link.targetRole === 'subject' ? 'incoming' : 'outgoing';
}
if (relationship === 'blocks') {
return link.targetRole === 'subject' ? 'outgoing' : 'incoming';
}
return undefined;
}
function buildAction(args: {
action: ParsedBoardTaskToolAction | undefined;
link: ParsedBoardTaskLink;
peerTask?: BoardTaskActivityTaskRef;
}): BoardTaskActivityAction | undefined {
const { action, link, peerTask } = args;
if (!action) return undefined;
const category = buildActionCategory(action);
const details = buildActionDetails(action);
const relationshipPerspective =
category === 'relationship' ? buildRelationshipPerspective(link, action) : undefined;
return {
canonicalToolName: action.canonicalToolName,
toolUseId: action.toolUseId,
category,
...(details ? { details } : {}),
...(category === 'relationship' && peerTask ? { peerTask } : {}),
...(relationshipPerspective ? { relationshipPerspective } : {}),
};
}
function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivityActor {
const memberName =
typeof message.agentName === 'string' && message.agentName.trim().length > 0
? message.agentName.trim()
: undefined;
return {
...(memberName ? { memberName } : {}),
role: memberName
? message.isSidechain
? 'member'
: 'lead'
: message.isSidechain
? 'member'
: 'unknown',
sessionId: message.sessionId,
...(message.agentId ? { agentId: message.agentId } : {}),
isSidechain: message.isSidechain,
};
}
function resolvePeerTask(
teamName: string,
currentLink: ParsedBoardTaskLink,
allLinks: ParsedBoardTaskLink[],
targetTask: TeamTask,
lookup: TaskLookup
): BoardTaskActivityTaskRef | undefined {
for (const link of allLinks) {
if (link === currentLink) continue;
if (link.toolUseId !== currentLink.toolUseId) continue;
if (locatorCouldMatchTask(link.task, targetTask, lookup)) continue;
return resolveLocatorToTaskRef(teamName, link.task, lookup);
}
return undefined;
}
function buildActorContext(
teamName: string,
actorContext: ParsedBoardTaskLink['actorContext'],
lookup: TaskLookup
): BoardTaskActivityRecord['actorContext'] {
return {
relation: actorContext.relation,
...(actorContext.activeTask
? { activeTask: resolveLocatorToTaskRef(teamName, actorContext.activeTask, lookup) }
: {}),
...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}),
...(actorContext.activeExecutionSeq
? { activeExecutionSeq: actorContext.activeExecutionSeq }
: {}),
};
}
function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityRecord): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.source.filePath !== right.source.filePath) {
return left.source.filePath.localeCompare(right.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) {
return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? '');
}
return left.id.localeCompare(right.id);
}
export class BoardTaskActivityRecordBuilder {
buildForTask(args: {
teamName: string;
targetTask: TeamTask;
tasks: TeamTask[];
messages: RawTaskActivityMessage[];
}): BoardTaskActivityRecord[] {
const lookup = buildTaskLookup(args.tasks);
const records: BoardTaskActivityRecord[] = [];
const seenIds = new Set<string>();
for (const message of args.messages) {
const actionMap = buildActionMap(message.boardTaskToolActions);
for (const link of message.boardTaskLinks) {
const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup);
if (
resolvedTask.taskRef?.taskId !== args.targetTask.id &&
!locatorCouldMatchTask(link.task, args.targetTask, lookup)
) {
continue;
}
const action =
link.linkKind === 'execution' || !link.toolUseId
? undefined
: actionMap.get(link.toolUseId);
const peerTask = resolvePeerTask(
args.teamName,
link,
message.boardTaskLinks,
args.targetTask,
lookup
);
const record: BoardTaskActivityRecord = {
id: [
message.uuid,
link.toolUseId ?? 'ambient',
link.task.ref,
link.targetRole,
link.linkKind,
].join(':'),
timestamp: message.timestamp,
task: resolvedTask,
linkKind: link.linkKind,
targetRole: link.targetRole,
actor: resolveActivityActor(message),
actorContext: buildActorContext(args.teamName, link.actorContext, lookup),
...(action ? { action: buildAction({ action, link, peerTask }) } : {}),
source: {
messageUuid: message.uuid,
filePath: message.filePath,
...(link.toolUseId ? { toolUseId: link.toolUseId } : {}),
sourceOrder: message.sourceOrder,
},
};
if (seenIds.has(record.id)) {
continue;
}
seenIds.add(record.id);
records.push(record);
}
}
return records.sort(compareRecords);
}
}

View file

@ -0,0 +1,37 @@
import { TeamTaskReader } from '../../TeamTaskReader';
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder';
import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader';
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
export class BoardTaskActivityRecordSource {
constructor(
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(),
private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder()
) {}
async getTaskRecords(teamName: string, taskId: string): Promise<BoardTaskActivityRecord[]> {
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.transcriptSourceLocator.listTranscriptFiles(teamName),
]);
const tasks = [...activeTasks, ...deletedTasks];
const targetTask = tasks.find((task) => task.id === taskId);
if (!targetTask || transcriptFiles.length === 0) {
return [];
}
const messages = await this.transcriptReader.readFiles(transcriptFiles);
return this.recordBuilder.buildForTask({
teamName,
targetTask,
tasks,
messages,
});
}
}

View file

@ -0,0 +1,21 @@
import { BoardTaskActivityEntryBuilder } from './BoardTaskActivityEntryBuilder';
import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource';
import { isBoardTaskActivityReadEnabled } from './featureGates';
import type { BoardTaskActivityEntry } from '@shared/types';
export class BoardTaskActivityService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly entryBuilder: BoardTaskActivityEntryBuilder = new BoardTaskActivityEntryBuilder()
) {}
async getTaskActivity(teamName: string, taskId: string): Promise<BoardTaskActivityEntry[]> {
if (!isBoardTaskActivityReadEnabled()) {
return [];
}
const records = await this.recordSource.getTaskRecords(teamName, taskId);
return this.entryBuilder.buildFromRecords(records);
}
}

View file

@ -0,0 +1,125 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as readline from 'readline';
import { yieldToEventLoop } from '@main/utils/asyncYield';
import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache';
import {
parseBoardTaskLinks,
parseBoardTaskToolActions,
type ParsedBoardTaskLink,
type ParsedBoardTaskToolAction,
} from '../contract/BoardTaskTranscriptContract';
const logger = createLogger('Service:BoardTaskActivityTranscriptReader');
export interface RawTaskActivityMessage {
filePath: string;
uuid: string;
timestamp: string;
sessionId: string;
agentId?: string;
agentName?: string;
isSidechain: boolean;
boardTaskLinks: ParsedBoardTaskLink[];
boardTaskToolActions: ParsedBoardTaskToolAction[];
sourceOrder: number;
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
export class BoardTaskActivityTranscriptReader {
private readonly cache = new BoardTaskActivityParseCache<RawTaskActivityMessage[]>();
async readFiles(filePaths: string[]): Promise<RawTaskActivityMessage[]> {
const uniqueFilePaths = [...new Set(filePaths)].sort();
this.cache.retainOnly(new Set(uniqueFilePaths));
const parsedFiles = await Promise.all(
uniqueFilePaths.map((filePath) => this.readFile(filePath))
);
return parsedFiles.flat();
}
private async readFile(filePath: string): Promise<RawTaskActivityMessage[]> {
try {
const stat = await fs.stat(filePath);
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.parseFile(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch (error) {
logger.debug(`Skipping unreadable task-activity transcript ${filePath}: ${String(error)}`);
this.cache.clearForPath(filePath);
return [];
}
}
private async parseFile(filePath: string): Promise<RawTaskActivityMessage[]> {
const results: RawTaskActivityMessage[] = [];
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let sourceOrder = 0;
for await (const line of rl) {
if (!line.trim()) continue;
try {
const parsed = JSON.parse(line) as unknown;
const record = asRecord(parsed);
if (!record) continue;
const uuid = typeof record.uuid === 'string' ? record.uuid : '';
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : '';
const timestamp = typeof record.timestamp === 'string' ? record.timestamp : '';
if (!uuid || !sessionId || !timestamp) continue;
const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks);
if (boardTaskLinks.length === 0) continue;
sourceOrder += 1;
results.push({
filePath,
uuid,
timestamp,
sessionId,
agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
agentName: typeof record.agentName === 'string' ? record.agentName : undefined,
isSidechain: record.isSidechain === true,
boardTaskLinks,
boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions),
sourceOrder,
});
} catch (error) {
logger.debug(`Skipping malformed task-activity line in ${filePath}: ${String(error)}`);
}
if (sourceOrder > 0 && sourceOrder % 250 === 0) {
await yieldToEventLoop();
}
}
return results;
}
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isBoardTaskActivityReadEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED, true);
}

View file

@ -0,0 +1,308 @@
import { createLogger } from '@shared/utils/logger';
import type {
BoardTaskActivityLinkKind,
BoardTaskActivityPhase,
BoardTaskActivityTargetRole,
BoardTaskActorRelation,
BoardTaskLocator,
} from '@shared/types';
const logger = createLogger('Service:BoardTaskTranscriptContract');
export interface ParsedBoardTaskActorContext {
relation: BoardTaskActorRelation;
activeTask?: BoardTaskLocator;
activePhase?: BoardTaskActivityPhase;
activeExecutionSeq?: number;
}
export interface ParsedBoardTaskLink {
schemaVersion: 1;
toolUseId?: string;
task: BoardTaskLocator;
targetRole: BoardTaskActivityTargetRole;
linkKind: BoardTaskActivityLinkKind;
taskArgumentSlot?: 'taskId' | 'targetId';
actorContext: ParsedBoardTaskActorContext;
}
export interface ParsedBoardTaskToolAction {
schemaVersion: 1;
toolUseId: string;
canonicalToolName: string;
input?: {
status?: 'pending' | 'in_progress' | 'completed' | 'deleted';
owner?: string | null;
clarification?: 'lead' | 'user' | null;
reviewer?: string;
relationship?: 'blocked-by' | 'blocks' | 'related';
commentId?: string;
};
resultRefs?: {
commentId?: string;
attachmentId?: string;
filename?: string;
};
}
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function asNonEmptyString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function parseNullableOwner(value: unknown): string | null | undefined {
if (value === null) return null;
const normalized = asNonEmptyString(value);
if (!normalized) return undefined;
if (normalized === 'clear' || normalized === 'none') {
return null;
}
return normalized;
}
function parseStatus(
value: unknown
): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined {
const normalized = asNonEmptyString(value);
if (
normalized === 'pending' ||
normalized === 'in_progress' ||
normalized === 'completed' ||
normalized === 'deleted'
) {
return normalized;
}
return undefined;
}
function parseRelationship(value: unknown): 'blocked-by' | 'blocks' | 'related' | undefined {
const normalized = asNonEmptyString(value);
if (normalized === 'blocked-by' || normalized === 'blocks' || normalized === 'related') {
return normalized;
}
return undefined;
}
function parseClarification(value: unknown): 'lead' | 'user' | null | undefined {
if (value === null) return null;
const normalized = asNonEmptyString(value);
if (!normalized) return undefined;
if (normalized === 'lead' || normalized === 'user') {
return normalized;
}
if (normalized === 'clear') {
return null;
}
return undefined;
}
function noteReadDiagnostic(
event: string,
details: Record<string, string | number | undefined> = {}
): void {
const suffix = Object.entries(details)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`);
}
function parseSchemaVersion(record: Record<string, unknown>): 1 | null {
if (record.schemaVersion === 1) {
return 1;
}
if (record.version === 1) {
return 1;
}
return null;
}
export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null {
const record = asRecord(value);
if (!record) return null;
const ref = asNonEmptyString(record.ref);
const refKind = asNonEmptyString(record.refKind);
if (!ref || (refKind !== 'canonical' && refKind !== 'display' && refKind !== 'unknown')) {
return null;
}
const canonicalId = asNonEmptyString(record.canonicalId);
return {
ref,
refKind,
...(canonicalId ? { canonicalId } : {}),
};
}
function parseActorContext(value: unknown): ParsedBoardTaskActorContext | null {
const record = asRecord(value);
if (!record) return null;
const relation = asNonEmptyString(record.relation);
if (
relation !== 'same_task' &&
relation !== 'other_active_task' &&
relation !== 'idle' &&
relation !== 'ambiguous'
) {
return null;
}
const activeTask = parseBoardTaskLocator(record.activeTask);
const activePhase = asNonEmptyString(record.activePhase);
const activeExecutionSeq =
typeof record.activeExecutionSeq === 'number' && Number.isFinite(record.activeExecutionSeq)
? record.activeExecutionSeq
: undefined;
if (relation !== 'other_active_task') {
return { relation };
}
return {
relation,
...(activeTask ? { activeTask } : {}),
...(activePhase === 'work' || activePhase === 'review' ? { activePhase } : {}),
...(activeExecutionSeq ? { activeExecutionSeq } : {}),
};
}
export function parseBoardTaskLinks(value: unknown): ParsedBoardTaskLink[] {
if (!Array.isArray(value)) return [];
const parsed: ParsedBoardTaskLink[] = [];
for (const item of value) {
const record = asRecord(item);
if (!record) {
noteReadDiagnostic('link_parse_dropped', { reason: 'not_object' });
continue;
}
const schemaVersion = parseSchemaVersion(record);
if (schemaVersion !== 1) {
noteReadDiagnostic('link_parse_dropped', { reason: 'unsupported_version' });
continue;
}
const task = parseBoardTaskLocator(record.task);
const targetRole = asNonEmptyString(record.targetRole);
const linkKind = asNonEmptyString(record.linkKind);
const actorContext = parseActorContext(record.actorContext);
const rawTaskArgumentSlot = asNonEmptyString(record.taskArgumentSlot);
const taskArgumentSlot =
rawTaskArgumentSlot === 'taskId' || rawTaskArgumentSlot === 'targetId'
? rawTaskArgumentSlot
: undefined;
const toolUseId = asNonEmptyString(record.toolUseId);
if (!task) {
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_task' });
continue;
}
if (!actorContext) {
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_actor_context' });
continue;
}
if (targetRole !== 'subject' && targetRole !== 'related') {
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_target_role' });
continue;
}
if (linkKind !== 'execution' && linkKind !== 'lifecycle' && linkKind !== 'board_action') {
noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_link_kind' });
continue;
}
const sanitizedToolUseId = toolUseId;
const sanitizedTaskArgumentSlot = linkKind === 'execution' ? undefined : taskArgumentSlot;
parsed.push({
schemaVersion: 1,
task,
targetRole,
linkKind,
actorContext,
...(sanitizedToolUseId ? { toolUseId: sanitizedToolUseId } : {}),
...(sanitizedTaskArgumentSlot ? { taskArgumentSlot: sanitizedTaskArgumentSlot } : {}),
});
}
return parsed;
}
export function parseBoardTaskToolActions(value: unknown): ParsedBoardTaskToolAction[] {
if (!Array.isArray(value)) return [];
const parsed: ParsedBoardTaskToolAction[] = [];
for (const item of value) {
const record = asRecord(item);
if (!record) {
noteReadDiagnostic('action_parse_dropped', { reason: 'not_object' });
continue;
}
if (parseSchemaVersion(record) !== 1) {
noteReadDiagnostic('action_parse_dropped', { reason: 'unsupported_version' });
continue;
}
const toolUseId = asNonEmptyString(record.toolUseId);
const canonicalToolName = asNonEmptyString(record.canonicalToolName);
if (!toolUseId || !canonicalToolName) {
noteReadDiagnostic('action_parse_dropped', { reason: 'missing_identity' });
continue;
}
const inputRecord = asRecord(record.input);
const resultRefsRecord = asRecord(record.resultRefs);
parsed.push({
schemaVersion: 1,
toolUseId,
canonicalToolName,
...(inputRecord
? {
input: {
...(parseStatus(inputRecord.status) !== undefined
? { status: parseStatus(inputRecord.status) }
: {}),
...(parseNullableOwner(inputRecord.owner) !== undefined
? { owner: parseNullableOwner(inputRecord.owner) }
: {}),
...(parseClarification(inputRecord.clarification) !== undefined
? { clarification: parseClarification(inputRecord.clarification) }
: {}),
...(asNonEmptyString(inputRecord.reviewer)
? { reviewer: asNonEmptyString(inputRecord.reviewer) }
: {}),
...(parseRelationship(inputRecord.relationship) !== undefined
? { relationship: parseRelationship(inputRecord.relationship) }
: {}),
...(asNonEmptyString(inputRecord.commentId)
? { commentId: asNonEmptyString(inputRecord.commentId) }
: {}),
},
}
: {}),
...(resultRefsRecord
? {
resultRefs: {
...(asNonEmptyString(resultRefsRecord.commentId)
? { commentId: asNonEmptyString(resultRefsRecord.commentId) }
: {}),
...(asNonEmptyString(resultRefsRecord.attachmentId)
? { attachmentId: asNonEmptyString(resultRefsRecord.attachmentId) }
: {}),
...(asNonEmptyString(resultRefsRecord.filename)
? { filename: asNonEmptyString(resultRefsRecord.filename) }
: {}),
},
}
: {}),
});
}
return parsed;
}

View file

@ -0,0 +1,400 @@
import { TeamTaskReader } from '../../TeamTaskReader';
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator';
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService';
import type { ParsedMessage } from '@main/types';
import type { TeamTask, TaskWorkInterval } from '@shared/types';
import { getTaskDisplayId, taskMatchesRef } from '@shared/utils/taskIdentity';
const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const;
const MAX_EXAMPLES = 10;
export interface BoardTaskLogDiagnosticExample {
timestamp: string;
filePath: string;
messageUuid: string;
toolUseId?: string;
toolName: string;
isSidechain: boolean;
agentId?: string;
}
export interface BoardTaskLogDiagnosticsReport {
teamName: string;
requestedTaskRef: string;
task: {
taskId: string;
displayId: string;
subject: string;
status: TeamTask['status'];
owner?: string;
workIntervals: TaskWorkInterval[];
};
transcript: {
fileCount: number;
files: string[];
};
explicitRecords: {
total: number;
execution: number;
lifecycle: number;
boardAction: number;
participants: string[];
toolNames: string[];
};
intervalToolResults: {
total: number;
boardMcp: number;
worker: {
total: number;
explicitLinked: number;
missingExplicit: number;
examples: BoardTaskLogDiagnosticExample[];
};
};
stream: {
participants: string[];
defaultFilter: string;
segmentCount: number;
visibleToolNames: string[];
emptyPayloadExamples: BoardTaskLogDiagnosticExample[];
};
diagnosis: string[];
}
function normalizeRequestedTaskRef(taskRef: string): string {
return taskRef.trim().replace(/^#/, '');
}
function isBoardMcpToolName(toolName: string | undefined): boolean {
if (!toolName) return false;
const normalized = toolName.trim().toLowerCase();
return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix));
}
function isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): boolean {
if (!Number.isFinite(timestamp.getTime())) {
return false;
}
if (intervals.length === 0) {
return true;
}
const time = timestamp.getTime();
return intervals.some((interval) => {
const startedAt = Date.parse(interval.startedAt);
if (!Number.isFinite(startedAt) || time < startedAt) {
return false;
}
if (!interval.completedAt) {
return true;
}
const completedAt = Date.parse(interval.completedAt);
return !Number.isFinite(completedAt) || time <= completedAt;
});
}
function pushUnique(values: string[], value: string | undefined): void {
if (!value) return;
if (!values.includes(value)) {
values.push(value);
}
}
function pushExample(
examples: BoardTaskLogDiagnosticExample[],
example: BoardTaskLogDiagnosticExample
): void {
if (examples.length < MAX_EXAMPLES) {
examples.push(example);
}
}
function buildParticipantLabel(record: BoardTaskActivityRecord): string {
if (record.actor.memberName) {
return record.actor.memberName;
}
if (!record.actor.isSidechain || record.actor.role === 'lead') {
return 'lead session';
}
if (record.actor.agentId) {
return `member ${record.actor.agentId.slice(0, 8)}`;
}
return `member session ${record.actor.sessionId.slice(0, 8)}`;
}
function extractVisibleToolNames(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): string[] {
const toolNames: string[] = [];
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolCall of message.toolCalls) {
pushUnique(toolNames, toolCall.name);
}
}
}
}
return toolNames;
}
function buildStreamToolNameMap(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): Map<string, string> {
const toolNameByUseId = new Map<string, string>();
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolCall of message.toolCalls) {
toolNameByUseId.set(toolCall.id, toolCall.name);
}
}
}
}
return toolNameByUseId;
}
function isEmptyToolPayload(value: unknown): boolean {
if (value == null) return true;
if (typeof value === 'string') {
return value.trim().length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
return false;
}
function collectEmptyPayloadExamples(
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
): BoardTaskLogDiagnosticExample[] {
const examples: BoardTaskLogDiagnosticExample[] = [];
const toolNameByUseId = buildStreamToolNameMap(stream);
for (const segment of stream.segments) {
for (const chunk of segment.chunks) {
for (const message of chunk.rawMessages) {
for (const toolResult of message.toolResults) {
if (!isEmptyToolPayload(toolResult.content)) {
continue;
}
pushExample(examples, {
timestamp: message.timestamp.toISOString(),
filePath: 'stream',
messageUuid: message.uuid,
toolUseId: toolResult.toolUseId,
toolName: toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool',
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
const toolUseResult = message.toolUseResult;
if (!toolUseResult) {
continue;
}
const toolUseId =
typeof toolUseResult.toolUseId === 'string'
? toolUseResult.toolUseId
: message.sourceToolUseID;
const contentIsEmpty =
(!('content' in toolUseResult) || isEmptyToolPayload(toolUseResult.content)) &&
(!('message' in toolUseResult) || isEmptyToolPayload(toolUseResult.message));
if (!contentIsEmpty) {
continue;
}
pushExample(examples, {
timestamp: message.timestamp.toISOString(),
filePath: 'stream',
messageUuid: message.uuid,
...(toolUseId ? { toolUseId } : {}),
toolName: toolUseId ? (toolNameByUseId.get(toolUseId) ?? 'unknown tool') : 'unknown tool',
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
}
}
return examples;
}
function buildToolNameMap(parsedMessagesByFile: Map<string, ParsedMessage[]>): Map<string, string> {
const toolNameByUseId = new Map<string, string>();
for (const messages of parsedMessagesByFile.values()) {
for (const message of messages) {
for (const toolCall of message.toolCalls) {
toolNameByUseId.set(toolCall.id, toolCall.name);
}
}
}
return toolNameByUseId;
}
export class BoardTaskLogDiagnosticsService {
constructor(
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService()
) {}
async diagnose(teamName: string, taskRef: string): Promise<BoardTaskLogDiagnosticsReport> {
const normalizedRef = normalizeRequestedTaskRef(taskRef);
const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([
this.taskReader.getTasks(teamName),
this.taskReader.getDeletedTasks(teamName),
this.transcriptSourceLocator.listTranscriptFiles(teamName),
]);
const tasks = [...activeTasks, ...deletedTasks];
const task = tasks.find((candidate) => taskMatchesRef(candidate, normalizedRef));
if (!task) {
throw new Error(`Task "${taskRef}" was not found in team "${teamName}"`);
}
const records = await this.recordSource.getTaskRecords(teamName, task.id);
const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles);
const stream = await this.streamService.getTaskLogStream(teamName, task.id);
const toolNameByUseId = buildToolNameMap(parsedMessagesByFile);
const explicitExecutionKeys = new Set(
records
.filter((record) => record.linkKind === 'execution')
.map((record) => `${record.source.messageUuid}:${record.source.toolUseId ?? ''}`)
);
const workIntervals = Array.isArray(task.workIntervals) ? task.workIntervals : [];
const explicitParticipants: string[] = [];
const explicitToolNames: string[] = [];
for (const record of records) {
pushUnique(explicitParticipants, buildParticipantLabel(record));
pushUnique(explicitToolNames, record.action?.canonicalToolName);
}
let intervalToolResultTotal = 0;
let boardMcpToolResultTotal = 0;
let workerToolResultTotal = 0;
let explicitLinkedWorkerResultTotal = 0;
let missingExplicitWorkerResultTotal = 0;
const missingExplicitWorkerExamples: BoardTaskLogDiagnosticExample[] = [];
for (const [filePath, messages] of parsedMessagesByFile.entries()) {
for (const message of messages) {
if (message.type !== 'user' || message.toolResults.length === 0) {
continue;
}
if (!isWithinWorkIntervals(message.timestamp, workIntervals)) {
continue;
}
for (const toolResult of message.toolResults) {
intervalToolResultTotal += 1;
const toolName = toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool';
if (isBoardMcpToolName(toolName)) {
boardMcpToolResultTotal += 1;
continue;
}
workerToolResultTotal += 1;
const explicitKey = `${message.uuid}:${toolResult.toolUseId}`;
if (explicitExecutionKeys.has(explicitKey)) {
explicitLinkedWorkerResultTotal += 1;
continue;
}
missingExplicitWorkerResultTotal += 1;
pushExample(missingExplicitWorkerExamples, {
timestamp: message.timestamp.toISOString(),
filePath,
messageUuid: message.uuid,
toolUseId: toolResult.toolUseId,
toolName,
isSidechain: message.isSidechain,
...(message.agentId ? { agentId: message.agentId } : {}),
});
}
}
}
const diagnosis: string[] = [];
if (transcriptFiles.length === 0) {
diagnosis.push('No transcript files were found for this team.');
}
if (records.length === 0) {
diagnosis.push('No explicit task-linked activity records were found for this task.');
}
if (missingExplicitWorkerResultTotal > 0) {
diagnosis.push(
`Only board MCP actions are explicit for part of this task history. Found ${missingExplicitWorkerResultTotal} worker tool result(s) inside task work intervals without boardTaskLinks, so Task Log Stream cannot safely include them.`
);
}
if (
missingExplicitWorkerResultTotal > 0 &&
extractVisibleToolNames(stream).every((toolName) => isBoardMcpToolName(toolName))
) {
diagnosis.push(
'Current stream visibility matches the data gap: the visible tools are MCP board actions, while worker tools exist in transcript but are unlinked.'
);
}
const emptyPayloadExamples = collectEmptyPayloadExamples(stream);
if (emptyPayloadExamples.length > 0) {
diagnosis.push(
`Found ${emptyPayloadExamples.length} tool result payload(s) with empty rendered content in the current stream. This explains empty success/output blocks.`
);
}
if (diagnosis.length === 0) {
diagnosis.push('No obvious task-log data gap was detected by diagnostics.');
}
return {
teamName,
requestedTaskRef: taskRef,
task: {
taskId: task.id,
displayId: getTaskDisplayId(task),
subject: task.subject,
status: task.status,
...(task.owner ? { owner: task.owner } : {}),
workIntervals,
},
transcript: {
fileCount: transcriptFiles.length,
files: transcriptFiles,
},
explicitRecords: {
total: records.length,
execution: records.filter((record) => record.linkKind === 'execution').length,
lifecycle: records.filter((record) => record.linkKind === 'lifecycle').length,
boardAction: records.filter((record) => record.linkKind === 'board_action').length,
participants: explicitParticipants,
toolNames: explicitToolNames,
},
intervalToolResults: {
total: intervalToolResultTotal,
boardMcp: boardMcpToolResultTotal,
worker: {
total: workerToolResultTotal,
explicitLinked: explicitLinkedWorkerResultTotal,
missingExplicit: missingExplicitWorkerResultTotal,
examples: missingExplicitWorkerExamples,
},
},
stream: {
participants: stream.participants.map((participant) => participant.label),
defaultFilter: stream.defaultFilter,
segmentCount: stream.segments.length,
visibleToolNames: extractVisibleToolNames(stream),
emptyPayloadExamples,
},
diagnosis,
};
}
}

View file

@ -0,0 +1,165 @@
import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder';
import { createLogger } from '@shared/utils/logger';
import * as fs from 'fs/promises';
import * as path from 'path';
import { TeamConfigReader } from '../../TeamConfigReader';
import type { TeamConfig } from '@shared/types';
const logger = createLogger('Service:TeamTranscriptSourceLocator');
function trimTrailingSlashes(value: string): string {
let end = value.length;
while (end > 0) {
const ch = value.charCodeAt(end - 1);
if (ch === 47 || ch === 92) {
end -= 1;
continue;
}
break;
}
return end === value.length ? value : value.slice(0, end);
}
export interface TeamTranscriptSourceContext {
projectDir: string;
projectId: string;
config: TeamConfig;
sessionIds: string[];
transcriptFiles: string[];
}
export class TeamTranscriptSourceLocator {
constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {}
async getContext(teamName: string): Promise<TeamTranscriptSourceContext | null> {
const config = await this.configReader.getConfig(teamName);
if (!config?.projectPath) {
return null;
}
const normalizedProjectPath = trimTrailingSlashes(config.projectPath);
let projectId = encodePath(normalizedProjectPath);
let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId));
try {
const stat = await fs.stat(projectDir);
if (!stat.isDirectory()) {
throw new Error('not a directory');
}
} catch {
const leadSessionId =
typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0
? config.leadSessionId.trim()
: null;
if (leadSessionId) {
try {
const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true });
for (const entry of projectEntries) {
if (!entry.isDirectory()) continue;
const candidateDir = path.join(getProjectsBasePath(), entry.name);
try {
await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`));
projectDir = candidateDir;
projectId = entry.name;
break;
} catch {
// not this project
}
}
} catch {
// best-effort fallback
}
}
}
const sessionIds = await this.discoverSessionIds(projectDir, config);
const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds);
return { projectDir, projectId, config, sessionIds, transcriptFiles };
}
async listTranscriptFiles(teamName: string): Promise<string[]> {
const context = await this.getContext(teamName);
return context?.transcriptFiles ?? [];
}
private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise<string[]> {
const knownSessionIds = new Set<string>();
if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) {
knownSessionIds.add(config.leadSessionId.trim());
}
if (Array.isArray(config.sessionHistory)) {
for (const sessionId of config.sessionHistory) {
if (typeof sessionId === 'string' && sessionId.trim().length > 0) {
knownSessionIds.add(sessionId.trim());
}
}
}
let discoveredSessionDirs: string[] = [];
try {
const dirEntries = await fs.readdir(projectDir, { withFileTypes: true });
discoveredSessionDirs = dirEntries
.filter((entry) => entry.isDirectory())
.map((entry) => entry.name);
} catch {
logger.debug(`Cannot read transcript project dir: ${projectDir}`);
}
if (knownSessionIds.size === 0) {
return discoveredSessionDirs.sort();
}
const verifiedSessionIds: string[] = [];
for (const sessionId of knownSessionIds) {
try {
const stat = await fs.stat(path.join(projectDir, sessionId));
if (stat.isDirectory()) {
verifiedSessionIds.push(sessionId);
}
} catch {
// ignore stale config session
}
}
return Array.from(
new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs])
).sort();
}
private async listTranscriptFilesForSessions(
projectDir: string,
sessionIds: string[]
): Promise<string[]> {
const transcriptFiles = new Set<string>();
for (const sessionId of sessionIds) {
const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`);
try {
const stat = await fs.stat(mainTranscript);
if (stat.isFile()) {
transcriptFiles.add(mainTranscript);
}
} catch {
// ignore missing root transcript
}
const subagentsDir = path.join(projectDir, sessionId, 'subagents');
try {
const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true });
for (const entry of dirEntries) {
if (!entry.isFile()) continue;
if (!entry.name.endsWith('.jsonl')) continue;
if (!entry.name.startsWith('agent-')) continue;
if (entry.name.startsWith('agent-acompact')) continue;
transcriptFiles.add(path.join(subagentsDir, entry.name));
}
} catch {
// ignore missing subagent dir
}
}
return [...transcriptFiles].sort();
}
}

View file

@ -0,0 +1,11 @@
import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder';
import type { EnhancedChunk, ParsedMessage } from '@main/types';
export class BoardTaskExactLogChunkBuilder {
constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {}
buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] {
return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true });
}
}

View file

@ -0,0 +1,364 @@
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
import { createLogger } from '@shared/utils/logger';
import type { ContentBlock, ParsedMessage } from '@main/types';
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
import type {
BoardTaskExactLogDetailCandidate,
BoardTaskExactLogBundleCandidate,
} from './BoardTaskExactLogTypes';
const logger = createLogger('Service:BoardTaskExactLogDetailSelector');
interface TentativeFilteredMessage {
original: ParsedMessage;
filteredContent: ParsedMessage['content'];
matchedToolUseId?: string;
}
function isToolAnchoredOutputMessage(
message: ParsedMessage,
toolUseId: string | undefined
): boolean {
return Boolean(toolUseId && message.sourceToolUseID === toolUseId);
}
function noteExactDiagnostic(
event: string,
details: Record<string, string | number | undefined> = {}
): void {
const suffix = Object.entries(details)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`);
}
function keepExplicitTextualBlock(block: ContentBlock): boolean {
return block.type === 'text' || block.type === 'image';
}
function cloneBlock<T extends ContentBlock>(block: T): T {
if (block.type === 'tool_use') {
return {
...block,
input: { ...(block.input ?? {}) },
} as T;
}
if (block.type === 'tool_result') {
return {
...block,
content: Array.isArray(block.content)
? block.content.map((child) => cloneBlock(child))
: block.content,
} as T;
}
if (block.type === 'image') {
return {
...block,
source: { ...block.source },
} as T;
}
return { ...block } as T;
}
function filterAssistantContent(
content: ContentBlock[],
toolUseId: string | undefined,
explicitMessageLinked: boolean
): ContentBlock[] {
const kept: ContentBlock[] = [];
for (const block of content) {
if (block.type === 'tool_use') {
if (toolUseId && block.id === toolUseId) {
kept.push(cloneBlock(block));
}
continue;
}
if (block.type === 'thinking') {
continue;
}
if (explicitMessageLinked && keepExplicitTextualBlock(block)) {
kept.push(cloneBlock(block));
}
}
return kept;
}
function filterUserArrayContent(
content: ContentBlock[],
toolUseId: string | undefined,
explicitMessageLinked: boolean
): ContentBlock[] {
const kept: ContentBlock[] = [];
for (const block of content) {
if (block.type === 'tool_result') {
if (toolUseId && block.tool_use_id === toolUseId) {
kept.push(cloneBlock(block));
}
continue;
}
if (explicitMessageLinked && keepExplicitTextualBlock(block)) {
kept.push(cloneBlock(block));
}
}
return kept;
}
function filterMessageForCandidate(args: {
message: ParsedMessage;
candidate: BoardTaskExactLogBundleCandidate;
explicitMessageIds: Set<string>;
}): TentativeFilteredMessage | null {
const { message, candidate, explicitMessageIds } = args;
const explicitMessageLinked = explicitMessageIds.has(message.uuid);
const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined;
const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId);
if (typeof message.content === 'string') {
if (!explicitMessageLinked && !anchoredOutputLinked) {
return null;
}
return {
original: message,
filteredContent: message.content,
...(toolUseId ? { matchedToolUseId: toolUseId } : {}),
};
}
let filteredBlocks: ContentBlock[] = [];
if (message.type === 'assistant') {
filteredBlocks = filterAssistantContent(
message.content,
toolUseId,
explicitMessageLinked || anchoredOutputLinked
);
} else if (message.type === 'user') {
filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked);
} else {
filteredBlocks = explicitMessageLinked
? message.content.filter(keepExplicitTextualBlock).map((block) => cloneBlock(block))
: [];
}
if (filteredBlocks.length === 0) {
return null;
}
return {
original: message,
filteredContent: filteredBlocks,
...(toolUseId ? { matchedToolUseId: toolUseId } : {}),
};
}
function rebuildParsedMessage(
message: ParsedMessage,
filteredContent: ParsedMessage['content'],
keptAssistantUuids: Set<string>,
matchedToolUseId?: string
): ParsedMessage {
const {
toolCalls: _originalToolCalls,
toolResults: _originalToolResults,
sourceToolUseID: _originalSourceToolUseID,
sourceToolAssistantUUID: _originalSourceToolAssistantUUID,
toolUseResult: _originalToolUseResult,
...baseMessage
} = message;
const toolCalls = extractToolCalls(filteredContent);
const toolResults = extractToolResults(filteredContent);
const singleToolResult = toolResults.length === 1 ? toolResults[0] : undefined;
const matchedToolUseResultId =
message.toolUseResult &&
typeof message.toolUseResult.toolUseId === 'string' &&
message.toolUseResult.toolUseId === matchedToolUseId
? matchedToolUseId
: undefined;
const matchedSourceToolUseId =
matchedToolUseId &&
(message.sourceToolUseID === matchedToolUseId ||
singleToolResult?.toolUseId === matchedToolUseId ||
matchedToolUseResultId === matchedToolUseId)
? matchedToolUseId
: undefined;
const matchedSourceToolAssistantUUID =
matchedToolUseId &&
message.sourceToolAssistantUUID &&
keptAssistantUuids.has(message.sourceToolAssistantUUID)
? message.sourceToolAssistantUUID
: undefined;
const toolUseResult =
matchedToolUseId &&
matchedSourceToolUseId === matchedToolUseId &&
singleToolResult?.toolUseId === matchedToolUseId
? message.toolUseResult
: undefined;
return {
...baseMessage,
content: filteredContent,
toolCalls,
toolResults,
...(matchedSourceToolUseId ? { sourceToolUseID: matchedSourceToolUseId } : {}),
...(matchedSourceToolAssistantUUID
? { sourceToolAssistantUUID: matchedSourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
};
}
function anchorEvidenceRank(message: ParsedMessage, toolUseId: string | undefined): number {
if (message.type !== 'assistant' || !toolUseId) {
return 0;
}
if (Array.isArray(message.content)) {
for (const block of message.content) {
if (block.type === 'tool_use' && block.id === toolUseId) {
return 2;
}
}
}
return message.sourceToolUseID === toolUseId ? 1 : 0;
}
function deduplicateAssistantMessagesByRequestId(
messages: ParsedMessage[],
toolUseId: string | undefined
): ParsedMessage[] {
const preferredAssistantIndexByRequestId = new Map<string, number>();
for (let i = 0; i < messages.length; i++) {
const message = messages[i];
if (message.type === 'assistant' && message.requestId) {
const existingIndex = preferredAssistantIndexByRequestId.get(message.requestId);
if (existingIndex === undefined) {
preferredAssistantIndexByRequestId.set(message.requestId, i);
continue;
}
const existingRank = anchorEvidenceRank(messages[existingIndex]!, toolUseId);
const nextRank = anchorEvidenceRank(message, toolUseId);
if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) {
preferredAssistantIndexByRequestId.set(message.requestId, i);
}
}
}
if (preferredAssistantIndexByRequestId.size === 0) {
return messages;
}
return messages.filter((message, index) => {
if (message.type !== 'assistant' || !message.requestId) {
return true;
}
return preferredAssistantIndexByRequestId.get(message.requestId) === index;
});
}
function sanitizeSourceAssistantLinks(messages: ParsedMessage[]): ParsedMessage[] {
const keptAssistantUuids = new Set(
messages.filter((message) => message.type === 'assistant').map((message) => message.uuid)
);
return messages.map((message) => {
if (
!message.sourceToolAssistantUUID ||
keptAssistantUuids.has(message.sourceToolAssistantUUID)
) {
return message;
}
const { sourceToolAssistantUUID: _ignored, ...rest } = message;
return rest;
});
}
export class BoardTaskExactLogDetailSelector {
selectDetail(args: {
candidate: BoardTaskExactLogBundleCandidate;
records: BoardTaskActivityRecord[];
parsedMessagesByFile: Map<string, ParsedMessage[]>;
}): BoardTaskExactLogDetailCandidate | null {
const { candidate, records, parsedMessagesByFile } = args;
const relevantRecords = records.filter((record) =>
candidate.records.some((row) => row.id === record.id)
);
if (relevantRecords.length === 0) {
noteExactDiagnostic('missing_records_for_detail', { id: candidate.id });
return null;
}
const parsedMessages = parsedMessagesByFile.get(candidate.source.filePath);
if (!parsedMessages || parsedMessages.length === 0) {
noteExactDiagnostic('missing_parsed_messages', { filePath: candidate.source.filePath });
return null;
}
const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid));
const tentative: TentativeFilteredMessage[] = [];
for (const message of parsedMessages) {
const filtered = filterMessageForCandidate({
message,
candidate,
explicitMessageIds,
});
if (filtered) {
tentative.push(filtered);
}
}
if (tentative.length === 0) {
noteExactDiagnostic('empty_filtered_bundle', { id: candidate.id });
return null;
}
const keptAssistantUuids = new Set(
tentative
.filter((entry) => entry.original.type === 'assistant')
.map((entry) => entry.original.uuid)
);
const rebuilt = tentative.map((entry) =>
rebuildParsedMessage(
entry.original,
entry.filteredContent,
keptAssistantUuids,
entry.matchedToolUseId
)
);
const deduped = deduplicateAssistantMessagesByRequestId(
rebuilt,
candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined
);
const sanitized = sanitizeSourceAssistantLinks(deduped);
if (sanitized.length === 0) {
noteExactDiagnostic('empty_deduped_bundle', { id: candidate.id });
return null;
}
return {
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records: candidate.records,
filteredMessages: sanitized,
};
}
}

View file

@ -0,0 +1,76 @@
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import { BoardTaskExactLogChunkBuilder } from './BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogDetailSelector } from './BoardTaskExactLogDetailSelector';
import { BoardTaskExactLogStrictParser } from './BoardTaskExactLogStrictParser';
import { isBoardTaskExactLogsReadEnabled } from './featureGates';
import { getBoardTaskExactLogFileVersions } from './fileVersions';
import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector';
import type { BoardTaskExactLogDetailResult } from '@shared/types';
export class BoardTaskExactLogDetailService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
) {}
async getTaskExactLogDetail(
teamName: string,
taskId: string,
exactLogId: string,
expectedSourceGeneration: string
): Promise<BoardTaskExactLogDetailResult> {
if (!isBoardTaskExactLogsReadEnabled()) {
return { status: 'missing' };
}
const records = await this.recordSource.getTaskRecords(teamName, taskId);
if (records.length === 0) {
return { status: 'missing' };
}
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
records.map((record) => record.source.filePath)
);
const candidate = this.summarySelector
.selectSummaries({
records,
fileVersionsByPath,
})
.find((item) => item.id === exactLogId);
if (!candidate) {
return { status: 'missing' };
}
if (!candidate.canLoadDetail) {
return { status: 'missing' };
}
if (candidate.sourceGeneration !== expectedSourceGeneration) {
return { status: 'stale' };
}
const parsedMessagesByFile = await this.strictParser.parseFiles([candidate.source.filePath]);
const detailCandidate = this.detailSelector.selectDetail({
candidate,
records,
parsedMessagesByFile,
});
if (!detailCandidate) {
return { status: 'missing' };
}
const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages);
return {
status: 'ok',
detail: {
id: detailCandidate.id,
chunks,
},
};
}
}

View file

@ -0,0 +1,106 @@
import { createLogger } from '@shared/utils/logger';
import { createReadStream } from 'fs';
import * as fs from 'fs/promises';
import * as readline from 'readline';
import { yieldToEventLoop } from '@main/utils/asyncYield';
import { parseJsonlLine } from '@main/utils/jsonl';
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
import type { ParsedMessage } from '@main/types';
const logger = createLogger('Service:BoardTaskExactLogStrictParser');
function asRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
}
function hasStrictTimestamp(record: Record<string, unknown>): boolean {
if (typeof record.timestamp !== 'string' || record.timestamp.trim().length === 0) {
return false;
}
return Number.isFinite(Date.parse(record.timestamp));
}
export class BoardTaskExactLogStrictParser {
constructor(
private readonly cache: BoardTaskExactLogsParseCache = new BoardTaskExactLogsParseCache()
) {}
async parseFiles(filePaths: string[]): Promise<Map<string, ParsedMessage[]>> {
const uniquePaths = [...new Set(filePaths)].sort();
this.cache.retainOnly(new Set(uniquePaths));
const results = await Promise.all(
uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const)
);
return new Map(results);
}
private async parseFile(filePath: string): Promise<ParsedMessage[]> {
try {
const stat = await fs.stat(filePath);
const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size);
if (cached) {
return cached;
}
const inFlight = this.cache.getInFlight(filePath);
if (inFlight) {
return inFlight;
}
const promise = this.readStrictFile(filePath);
this.cache.setInFlight(filePath, promise);
try {
const parsed = await promise;
this.cache.set(filePath, stat.mtimeMs, stat.size, parsed);
return parsed;
} finally {
this.cache.clearInFlight(filePath);
}
} catch (error) {
logger.debug(`Skipping unreadable exact-log transcript ${filePath}: ${String(error)}`);
this.cache.clearForPath(filePath);
return [];
}
}
private async readStrictFile(filePath: string): Promise<ParsedMessage[]> {
const results: ParsedMessage[] = [];
const stream = createReadStream(filePath, { encoding: 'utf8' });
const rl = readline.createInterface({
input: stream,
crlfDelay: Infinity,
});
let lineCount = 0;
for await (const line of rl) {
if (!line.trim()) continue;
lineCount += 1;
try {
const raw = JSON.parse(line) as unknown;
const record = asRecord(raw);
if (!record || !hasStrictTimestamp(record)) {
continue;
}
const parsed = parseJsonlLine(line);
if (parsed) {
results.push(parsed);
}
} catch (error) {
logger.debug(`Skipping malformed exact-log line in ${filePath}: ${String(error)}`);
}
if (lineCount % 250 === 0) {
await yieldToEventLoop();
}
}
return results;
}
}

View file

@ -0,0 +1,227 @@
import { createHash } from 'crypto';
import { describeBoardTaskActivityLabel } from '@shared/utils/boardTaskActivityLabels';
import { createLogger } from '@shared/utils/logger';
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
import type {
BoardTaskExactLogAnchor,
BoardTaskExactLogBundleCandidate,
BoardTaskExactLogFileVersion,
} from './BoardTaskExactLogTypes';
const logger = createLogger('Service:BoardTaskExactLogSummarySelector');
function noteExactDiagnostic(
event: string,
details: Record<string, string | number | undefined> = {}
): void {
const suffix = Object.entries(details)
.filter(([, value]) => value !== undefined)
.map(([key, value]) => `${key}=${String(value)}`)
.join(' ');
logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`);
}
function compareCandidateTimestamps(
left: BoardTaskActivityRecord,
right: BoardTaskActivityRecord
): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
return left.id.localeCompare(right.id);
}
function buildMessageGroupKey(record: BoardTaskActivityRecord): string {
return `${record.source.filePath}:${record.source.messageUuid}`;
}
function buildToolAnchor(
filePath: string,
messageUuid: string,
toolUseId: string
): BoardTaskExactLogAnchor {
return {
kind: 'tool',
filePath,
messageUuid,
toolUseId,
};
}
function buildMessageAnchor(filePath: string, messageUuid: string): BoardTaskExactLogAnchor {
return {
kind: 'message',
filePath,
messageUuid,
};
}
function anchorId(anchor: BoardTaskExactLogAnchor): string {
return anchor.kind === 'tool'
? `tool:${anchor.filePath}:${anchor.toolUseId ?? ''}`
: `message:${anchor.filePath}:${anchor.messageUuid}`;
}
function sourceGenerationFor(
anchor: BoardTaskExactLogAnchor,
version: BoardTaskExactLogFileVersion | undefined
): string | null {
if (!version) return null;
const hash = createHash('sha1');
hash.update(anchor.filePath);
hash.update('\0');
hash.update(String(version.size));
hash.update('\0');
hash.update(String(version.mtimeMs));
return hash.digest('hex');
}
function chooseSummaryRecord(
records: BoardTaskActivityRecord[],
anchor: BoardTaskExactLogAnchor
): BoardTaskActivityRecord | null {
if (records.length === 0) {
return null;
}
const anchoredRecords =
anchor.kind === 'tool' && anchor.toolUseId
? records.filter(
(record) =>
record.source.toolUseId === anchor.toolUseId ||
record.action?.toolUseId === anchor.toolUseId
)
: records;
const candidates = anchoredRecords.length > 0 ? anchoredRecords : records;
return (
candidates.find((record) => record.action?.canonicalToolName) ??
candidates.find((record) => record.linkKind !== 'execution' && record.action) ??
candidates[0] ??
null
);
}
export class BoardTaskExactLogSummarySelector {
selectSummaries(args: {
records: BoardTaskActivityRecord[];
fileVersionsByPath: Map<string, BoardTaskExactLogFileVersion>;
}): BoardTaskExactLogBundleCandidate[] {
const byMessage = new Map<string, BoardTaskActivityRecord[]>();
for (const record of args.records) {
const key = buildMessageGroupKey(record);
const bucket = byMessage.get(key) ?? [];
bucket.push(record);
byMessage.set(key, bucket);
}
const groups = new Map<
string,
{ anchor: BoardTaskExactLogAnchor; records: BoardTaskActivityRecord[] }
>();
for (const messageRecords of byMessage.values()) {
const sortedMessageRecords = [...messageRecords].sort(compareCandidateTimestamps);
const toolUseIds = [
...new Set(sortedMessageRecords.map((record) => record.source.toolUseId).filter(Boolean)),
] as string[];
const singleToolUseId = toolUseIds.length === 1 ? toolUseIds[0] : null;
for (const record of sortedMessageRecords) {
let anchor: BoardTaskExactLogAnchor;
if (record.source.toolUseId) {
anchor = buildToolAnchor(
record.source.filePath,
record.source.messageUuid,
record.source.toolUseId
);
} else if (singleToolUseId) {
anchor = buildToolAnchor(
record.source.filePath,
record.source.messageUuid,
singleToolUseId
);
} else {
anchor = buildMessageAnchor(record.source.filePath, record.source.messageUuid);
}
const key = anchorId(anchor);
const existing = groups.get(key);
if (existing) {
existing.records.push(record);
} else {
groups.set(key, { anchor, records: [record] });
}
}
}
const candidates: BoardTaskExactLogBundleCandidate[] = [];
for (const [key, group] of groups) {
const sortedRecords = [...group.records].sort(compareCandidateTimestamps);
const primaryRecord = sortedRecords[0];
if (!primaryRecord) {
continue;
}
const linkKinds = [...new Set(sortedRecords.map((record) => record.linkKind))];
const targetRoles = [...new Set(sortedRecords.map((record) => record.targetRole))];
const fileVersion = args.fileVersionsByPath.get(primaryRecord.source.filePath);
const sourceGeneration = sourceGenerationFor(group.anchor, fileVersion);
const summaryRecord = chooseSummaryRecord(sortedRecords, group.anchor) ?? primaryRecord;
const actionLabel = describeBoardTaskActivityLabel(summaryRecord);
const baseCandidate = {
id: key,
timestamp: primaryRecord.timestamp,
actor: primaryRecord.actor,
source: {
filePath: primaryRecord.source.filePath,
messageUuid: primaryRecord.source.messageUuid,
...(group.anchor.kind === 'tool' && group.anchor.toolUseId
? { toolUseId: group.anchor.toolUseId }
: {}),
sourceOrder: primaryRecord.source.sourceOrder,
},
records: sortedRecords,
anchor: group.anchor,
actionLabel,
...(summaryRecord.action?.category
? { actionCategory: summaryRecord.action.category }
: {}),
...(summaryRecord.action?.canonicalToolName
? { canonicalToolName: summaryRecord.action.canonicalToolName }
: {}),
linkKinds,
targetRoles,
};
if (sourceGeneration) {
candidates.push({
...baseCandidate,
canLoadDetail: true,
sourceGeneration,
});
} else {
noteExactDiagnostic('non_expandable_summary', {
filePath: primaryRecord.source.filePath,
toolUseId: group.anchor.toolUseId,
});
candidates.push({
...baseCandidate,
canLoadDetail: false,
});
}
}
return candidates;
}
}

View file

@ -0,0 +1,77 @@
import type { ParsedMessage } from '@main/types';
import type {
BoardTaskActivityCategory,
BoardTaskActivityLinkKind,
BoardTaskActivityTargetRole,
BoardTaskExactLogActor,
BoardTaskExactLogSource,
BoardTaskExactLogSummary,
} from '@shared/types';
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
export interface BoardTaskExactLogFileVersion {
filePath: string;
mtimeMs: number;
size: number;
}
export interface BoardTaskExactLogAnchor {
kind: 'tool' | 'message';
filePath: string;
messageUuid: string;
toolUseId?: string;
}
export type BoardTaskExactLogBundleCandidate = {
id: string;
timestamp: string;
actor: BoardTaskExactLogActor;
source: BoardTaskExactLogSource;
records: BoardTaskActivityRecord[];
anchor: BoardTaskExactLogAnchor;
actionLabel: string;
actionCategory?: BoardTaskActivityCategory;
canonicalToolName?: string;
linkKinds: BoardTaskActivityLinkKind[];
targetRoles: BoardTaskActivityTargetRole[];
} & ({ canLoadDetail: true; sourceGeneration: string } | { canLoadDetail: false });
export interface BoardTaskExactLogDetailCandidate {
id: string;
timestamp: string;
actor: BoardTaskExactLogActor;
source: BoardTaskExactLogSource;
records: BoardTaskActivityRecord[];
filteredMessages: ParsedMessage[];
}
export function mapCandidateToSummary(
candidate: BoardTaskExactLogBundleCandidate
): BoardTaskExactLogSummary {
return candidate.canLoadDetail
? {
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
anchorKind: candidate.anchor.kind,
actionLabel: candidate.actionLabel,
...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}),
...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}),
linkKinds: candidate.linkKinds,
canLoadDetail: true,
sourceGeneration: candidate.sourceGeneration,
}
: {
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
anchorKind: candidate.anchor.kind,
actionLabel: candidate.actionLabel,
...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}),
...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}),
linkKinds: candidate.linkKinds,
canLoadDetail: false,
};
}

View file

@ -0,0 +1,35 @@
import { BoardTaskActivityParseCache } from '../activity/BoardTaskActivityParseCache';
import type { ParsedMessage } from '@main/types';
export class BoardTaskExactLogsParseCache {
private readonly cache = new BoardTaskActivityParseCache<ParsedMessage[]>();
getIfFresh(filePath: string, mtimeMs: number, size: number): ParsedMessage[] | null {
return this.cache.getIfFresh(filePath, mtimeMs, size);
}
getInFlight(filePath: string): Promise<ParsedMessage[]> | null {
return this.cache.getInFlight(filePath);
}
setInFlight(filePath: string, promise: Promise<ParsedMessage[]>): void {
this.cache.setInFlight(filePath, promise);
}
clearInFlight(filePath: string): void {
this.cache.clearInFlight(filePath);
}
set(filePath: string, mtimeMs: number, size: number, value: ParsedMessage[]): void {
this.cache.set(filePath, mtimeMs, size, value);
}
clearForPath(filePath: string): void {
this.cache.clearForPath(filePath);
}
retainOnly(filePaths: Set<string>): void {
this.cache.retainOnly(filePaths);
}
}

View file

@ -0,0 +1,63 @@
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import { isBoardTaskExactLogsReadEnabled } from './featureGates';
import { getBoardTaskExactLogFileVersions } from './fileVersions';
import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector';
import { mapCandidateToSummary } from './BoardTaskExactLogTypes';
import type { BoardTaskExactLogSummariesResponse } from '@shared/types';
function compareSummaries(
left: BoardTaskExactLogSummariesResponse['items'][number],
right: BoardTaskExactLogSummariesResponse['items'][number]
): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.source.filePath !== right.source.filePath) {
return left.source.filePath.localeCompare(right.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) {
return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? '');
}
return left.id.localeCompare(right.id);
}
export class BoardTaskExactLogsService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector()
) {}
async getTaskExactLogSummaries(
teamName: string,
taskId: string
): Promise<BoardTaskExactLogSummariesResponse> {
if (!isBoardTaskExactLogsReadEnabled()) {
return { items: [] };
}
const records = await this.recordSource.getTaskRecords(teamName, taskId);
if (records.length === 0) {
return { items: [] };
}
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
records.map((record) => record.source.filePath)
);
const items = this.summarySelector
.selectSummaries({
records,
fileVersionsByPath,
})
.map(mapCandidateToSummary)
.sort(compareSummaries);
return { items };
}
}

View file

@ -0,0 +1,18 @@
function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean {
if (value == null) {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isBoardTaskExactLogsReadEnabled(): boolean {
return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true);
}

View file

@ -0,0 +1,33 @@
import * as fs from 'fs/promises';
import type { BoardTaskExactLogFileVersion } from './BoardTaskExactLogTypes';
export async function getBoardTaskExactLogFileVersions(
filePaths: Iterable<string>
): Promise<Map<string, BoardTaskExactLogFileVersion>> {
const uniqueFilePaths = [...new Set(filePaths)];
const results = await Promise.all(
uniqueFilePaths.map(async (filePath) => {
try {
const stat = await fs.stat(filePath);
if (!stat.isFile()) {
return null;
}
return {
filePath,
mtimeMs: stat.mtimeMs,
size: stat.size,
} satisfies BoardTaskExactLogFileVersion;
} catch {
return null;
}
})
);
const byPath = new Map<string, BoardTaskExactLogFileVersion>();
for (const item of results) {
if (!item) continue;
byPath.set(item.filePath, item);
}
return byPath;
}

View file

@ -0,0 +1,858 @@
import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction';
import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates';
import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions';
import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector';
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
import type {
BoardTaskActivityCategory,
BoardTaskLogActor,
BoardTaskLogParticipant,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
} from '@shared/types';
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
interface StreamSlice {
id: string;
timestamp: string;
filePath: string;
participantKey: string;
actor: BoardTaskLogActor;
actionCategory?: BoardTaskActivityCategory;
filteredMessages: ParsedMessage[];
}
interface MergedMessageAccumulator {
message: ParsedMessage;
content: ParsedMessage['content'];
firstSeenOrder: number;
sourceToolUseIds: Set<string>;
sourceToolAssistantUUIDs: Set<string>;
toolUseResults: ToolUseResultData[];
}
function emptyResponse(): BoardTaskLogStreamResponse {
return {
participants: [],
defaultFilter: 'all',
segments: [],
};
}
function normalizeMemberName(value: string): string {
return value.trim().toLowerCase();
}
function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor {
return {
...(detail.memberName ? { memberName: detail.memberName } : {}),
role: detail.role,
sessionId: detail.sessionId,
...(detail.agentId ? { agentId: detail.agentId } : {}),
isSidechain: detail.isSidechain,
};
}
function buildParticipantKey(actor: BoardTaskLogActor): string {
if (actor.memberName) {
return `member:${normalizeMemberName(actor.memberName)}`;
}
if (!actor.isSidechain || actor.role === 'lead') {
return 'lead';
}
if (actor.agentId) {
return `sidechain-agent:${actor.agentId}`;
}
return `sidechain-session:${actor.sessionId}`;
}
function buildParticipantLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) {
return actor.memberName;
}
if (!actor.isSidechain || actor.role === 'lead') {
return 'lead session';
}
if (actor.agentId) {
return `member ${actor.agentId.slice(0, 8)}`;
}
return `member session ${actor.sessionId.slice(0, 8)}`;
}
function buildParticipant(
actor: BoardTaskLogActor,
participantKey: string
): BoardTaskLogParticipant {
return {
key: participantKey,
label: buildParticipantLabel(actor),
role: actor.role,
isLead: participantKey === 'lead',
isSidechain: actor.isSidechain,
};
}
function hasNamedParticipant(actor: BoardTaskLogActor): boolean {
return typeof actor.memberName === 'string' && actor.memberName.trim().length > 0;
}
function hasToolUseBlock(
content: ParsedMessage['content'],
toolUseId: string | undefined
): boolean {
if (!toolUseId || typeof content === 'string') {
return false;
}
return content.some((block) => block.type === 'tool_use' && block.id === toolUseId);
}
function looksLikeJsonPayload(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith('{') || trimmed.startsWith('[');
}
function parseJsonLikeString(value: string): unknown {
const trimmed = value.trim();
if (!looksLikeJsonPayload(trimmed)) {
return null;
}
try {
return JSON.parse(trimmed);
} catch {
return null;
}
}
function extractBoardToolOutputText(
toolName: string | undefined,
parsedPayload: unknown
): string | null {
if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') {
return null;
}
const payload = parsedPayload as Record<string, unknown>;
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
const comment = payload.comment as Record<string, unknown> | undefined;
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
return comment.text;
}
}
return null;
}
function collectTextBlockText(value: unknown): string {
if (!Array.isArray(value)) {
return '';
}
return value
.filter(
(child): child is Extract<ContentBlock, { type: 'text' }> =>
typeof child === 'object' &&
child !== null &&
'type' in child &&
child.type === 'text' &&
'text' in child &&
typeof child.text === 'string'
)
.map((child) => child.text)
.join('\n');
}
function isEmptyToolPayload(value: unknown): boolean {
if (value == null) {
return true;
}
if (typeof value === 'string') {
return value.trim().length === 0;
}
if (Array.isArray(value)) {
return value.length === 0;
}
return false;
}
function inferSingleToolUseId(message: ParsedMessage): string | undefined {
if (message.sourceToolUseID) {
return message.sourceToolUseID;
}
if (message.toolResults.length === 1) {
return message.toolResults[0]?.toolUseId;
}
if (!Array.isArray(message.content)) {
return undefined;
}
const uniqueIds = new Set(
message.content
.filter(
(block): block is Extract<ContentBlock, { type: 'tool_result' }> =>
block.type === 'tool_result'
)
.map((block) => block.tool_use_id)
);
return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined;
}
function sanitizeToolResultContent(
content: ContentBlock,
canonicalToolName?: string
): ContentBlock {
if (content.type !== 'tool_result') {
return cloneBlock(content);
}
if (typeof content.content === 'string') {
const parsedPayload = parseJsonLikeString(content.content);
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
if (typeof extractedText === 'string') {
return {
...content,
content: [{ type: 'text', text: extractedText }],
};
}
return parsedPayload ? { ...content, content: '' } : cloneBlock(content);
}
if (!Array.isArray(content.content)) {
return cloneBlock(content);
}
const jsonText = content.content
.filter((child): child is Extract<ContentBlock, { type: 'text' }> => child.type === 'text')
.map((child) => child.text)
.join('\n');
const parsedPayload = parseJsonLikeString(jsonText);
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
if (typeof extractedText === 'string') {
return {
...content,
content: extractedText,
};
}
const sanitizedChildren = content.content
.map((child) => {
if (child.type !== 'text') {
return cloneBlock(child);
}
return looksLikeJsonPayload(child.text) ? null : cloneBlock(child);
})
.filter((child): child is ContentBlock => child !== null);
if (sanitizedChildren.length === 0) {
return {
...content,
content: '',
};
}
return {
...content,
content: sanitizedChildren,
};
}
function sanitizeJsonLikeToolResultPayloads(
messages: ParsedMessage[],
canonicalToolName?: string
): ParsedMessage[] {
return messages.map((message) => {
let nextMessage = message;
const rawToolUseResult = message.toolUseResult as unknown;
if (
rawToolUseResult &&
typeof rawToolUseResult === 'object' &&
!Array.isArray(rawToolUseResult)
) {
const nextToolUseResult: Record<string, unknown> & {
content?: unknown;
message?: unknown;
} = { ...(rawToolUseResult as Record<string, unknown>) };
let toolUseResultChanged = false;
const extractedFromContent =
typeof nextToolUseResult.content === 'string'
? extractBoardToolOutputText(
canonicalToolName,
parseJsonLikeString(nextToolUseResult.content)
)
: null;
const extractedFromMessage =
typeof nextToolUseResult.message === 'string'
? extractBoardToolOutputText(
canonicalToolName,
parseJsonLikeString(nextToolUseResult.message)
)
: null;
if (typeof extractedFromContent === 'string') {
nextToolUseResult.content = extractedFromContent;
toolUseResultChanged = true;
}
if (
typeof nextToolUseResult.content === 'string' &&
looksLikeJsonPayload(nextToolUseResult.content)
) {
nextToolUseResult.content = '';
toolUseResultChanged = true;
}
if (typeof extractedFromMessage === 'string') {
nextToolUseResult.message = extractedFromMessage;
toolUseResultChanged = true;
}
if (
typeof nextToolUseResult.message === 'string' &&
looksLikeJsonPayload(nextToolUseResult.message)
) {
nextToolUseResult.message = '';
toolUseResultChanged = true;
}
if (toolUseResultChanged) {
nextMessage = {
...nextMessage,
toolUseResult: nextToolUseResult,
};
}
} else if (Array.isArray(rawToolUseResult)) {
const toolUseId = inferSingleToolUseId(message);
const jsonText = collectTextBlockText(rawToolUseResult);
const parsedPayload = parseJsonLikeString(jsonText);
const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload);
if (typeof extractedText === 'string' || parsedPayload) {
nextMessage = {
...nextMessage,
toolUseResult: {
...(toolUseId ? { toolUseId } : {}),
content: typeof extractedText === 'string' ? extractedText : '',
},
};
}
}
if (typeof message.content === 'string') {
return nextMessage;
}
let changed = false;
const nextContent = message.content.map((block) => {
if (block.type !== 'tool_result') {
return block;
}
const sanitized = sanitizeToolResultContent(block, canonicalToolName);
if (JSON.stringify(sanitized) !== JSON.stringify(block)) {
changed = true;
}
return sanitized;
});
if (!changed) {
return nextMessage;
}
return {
...nextMessage,
content: nextContent,
};
});
}
function hasMeaningfulToolUseResult(message: ParsedMessage): boolean {
const rawToolUseResult = message.toolUseResult as unknown;
if (
!rawToolUseResult ||
typeof rawToolUseResult !== 'object' ||
Array.isArray(rawToolUseResult)
) {
return false;
}
const toolUseResult = rawToolUseResult as {
error?: unknown;
stderr?: unknown;
content?: unknown;
message?: unknown;
};
if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) {
return true;
}
if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) {
return true;
}
if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) {
return true;
}
if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) {
return true;
}
if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) {
return true;
}
if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) {
return true;
}
return false;
}
function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] {
return messages.filter((message) => {
if (
message.type !== 'user' ||
message.toolResults.length === 0 ||
typeof message.content === 'string'
) {
return true;
}
const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result');
if (hasNonToolResultContent) {
return true;
}
const allToolResultsEmpty = message.toolResults.every((toolResult) =>
isEmptyToolPayload(toolResult.content)
);
if (!allToolResultsEmpty) {
return true;
}
return hasMeaningfulToolUseResult(message);
});
}
function pruneToolAnchoredAssistantOutputMessages(
messages: ParsedMessage[],
toolUseId: string | undefined
): ParsedMessage[] {
if (!toolUseId) {
return messages;
}
return messages.filter((message) => {
if (message.type !== 'assistant') {
return true;
}
if (message.sourceToolUseID !== toolUseId) {
return true;
}
return hasToolUseBlock(message.content, toolUseId);
});
}
function filterReadOnlySlices(slices: StreamSlice[]): StreamSlice[] {
const participantHasNonRead = new Map<string, boolean>();
for (const slice of slices) {
if (slice.actionCategory && slice.actionCategory !== 'read') {
participantHasNonRead.set(slice.participantKey, true);
}
}
return slices.filter((slice) => {
const hasNonReadForParticipant = participantHasNonRead.get(slice.participantKey) === true;
if (!hasNonReadForParticipant) {
return true;
}
return slice.actionCategory !== 'read';
});
}
function compareCandidates(
left: {
id: string;
timestamp: string;
source: { filePath: string; sourceOrder: number; toolUseId?: string };
},
right: {
id: string;
timestamp: string;
source: { filePath: string; sourceOrder: number; toolUseId?: string };
}
): number {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return leftTs - rightTs;
}
if (left.source.filePath !== right.source.filePath) {
return left.source.filePath.localeCompare(right.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) {
return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? '');
}
return left.id.localeCompare(right.id);
}
function blockKey(block: ContentBlock): string {
return JSON.stringify(block);
}
function cloneBlock<T extends ContentBlock>(block: T): T {
if (block.type === 'tool_use') {
return {
...block,
input: { ...(block.input ?? {}) },
} as T;
}
if (block.type === 'tool_result') {
return {
...block,
content: Array.isArray(block.content)
? block.content.map((child) => cloneBlock(child))
: block.content,
} as T;
}
if (block.type === 'image') {
return {
...block,
source: { ...block.source },
} as T;
}
return { ...block } as T;
}
function cloneMessageContent(content: ParsedMessage['content']): ParsedMessage['content'] {
if (typeof content === 'string') {
return content;
}
return content.map((block) => cloneBlock(block));
}
function mergeMessageContent(
current: ParsedMessage['content'],
incoming: ParsedMessage['content']
): ParsedMessage['content'] {
if (typeof current === 'string') {
return current;
}
if (typeof incoming === 'string') {
return current;
}
const merged = current.map((block) => cloneBlock(block));
const seen = new Set(merged.map((block) => blockKey(block)));
for (const block of incoming) {
const key = blockKey(block);
if (seen.has(key)) continue;
merged.push(cloneBlock(block));
seen.add(key);
}
return merged;
}
function createAccumulator(
message: ParsedMessage,
firstSeenOrder: number
): MergedMessageAccumulator {
return {
message,
content: cloneMessageContent(message.content),
firstSeenOrder,
sourceToolUseIds: new Set(message.sourceToolUseID ? [message.sourceToolUseID] : []),
sourceToolAssistantUUIDs: new Set(
message.sourceToolAssistantUUID ? [message.sourceToolAssistantUUID] : []
),
toolUseResults: message.toolUseResult ? [message.toolUseResult] : [],
};
}
function updateAccumulator(accumulator: MergedMessageAccumulator, message: ParsedMessage): void {
accumulator.content = mergeMessageContent(accumulator.content, message.content);
if (message.sourceToolUseID) {
accumulator.sourceToolUseIds.add(message.sourceToolUseID);
}
if (message.sourceToolAssistantUUID) {
accumulator.sourceToolAssistantUUIDs.add(message.sourceToolAssistantUUID);
}
if (message.toolUseResult) {
accumulator.toolUseResults.push(message.toolUseResult);
}
}
function selectSingleValue(values: Set<string>): string | undefined {
if (values.size !== 1) return undefined;
return values.values().next().value;
}
function selectSingleToolUseResult(values: ToolUseResultData[]): ToolUseResultData | undefined {
if (values.length !== 1) return undefined;
return values[0];
}
function extractToolUseIdFromToolUseResult(
value: ToolUseResultData | undefined
): string | undefined {
if (!value || typeof value.toolUseId !== 'string') {
return undefined;
}
const trimmed = value.toolUseId.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function rebuildMergedMessage(
accumulator: MergedMessageAccumulator,
keptAssistantUuids: Set<string>
): ParsedMessage {
const {
toolCalls: _toolCalls,
toolResults: _toolResults,
sourceToolUseID: _sourceToolUseID,
sourceToolAssistantUUID: _sourceToolAssistantUUID,
toolUseResult: _toolUseResult,
...base
} = accumulator.message;
const toolCalls = extractToolCalls(accumulator.content);
const toolResults = extractToolResults(accumulator.content);
const singleToolUseResult = selectSingleToolUseResult(accumulator.toolUseResults);
const derivedToolUseId =
selectSingleValue(accumulator.sourceToolUseIds) ??
(toolResults.length === 1 ? toolResults[0]?.toolUseId : undefined) ??
extractToolUseIdFromToolUseResult(singleToolUseResult);
const sourceToolAssistantUUID = selectSingleValue(accumulator.sourceToolAssistantUUIDs);
const preservedSourceToolAssistantUUID =
sourceToolAssistantUUID && keptAssistantUuids.has(sourceToolAssistantUUID)
? sourceToolAssistantUUID
: undefined;
const toolUseResult = singleToolUseResult;
return {
...base,
content: accumulator.content,
toolCalls,
toolResults,
...(derivedToolUseId ? { sourceToolUseID: derivedToolUseId } : {}),
...(preservedSourceToolAssistantUUID
? { sourceToolAssistantUUID: preservedSourceToolAssistantUUID }
: {}),
...(toolUseResult ? { toolUseResult } : {}),
};
}
function mergeMessages(
details: Array<{ filePath: string; filteredMessages: ParsedMessage[] }>
): ParsedMessage[] {
const byMessageKey = new Map<string, MergedMessageAccumulator>();
let order = 0;
for (const detail of details) {
for (const message of detail.filteredMessages) {
const key = `${detail.filePath}:${message.uuid}`;
const existing = byMessageKey.get(key);
if (existing) {
updateAccumulator(existing, message);
} else {
byMessageKey.set(key, createAccumulator(message, order));
order += 1;
}
}
}
const mergedAccumulators = [...byMessageKey.values()].sort(
(left, right) => left.firstSeenOrder - right.firstSeenOrder
);
const keptAssistantUuids = new Set(
mergedAccumulators
.filter((entry) => entry.message.type === 'assistant')
.map((entry) => entry.message.uuid)
);
return mergedAccumulators.map((entry) => rebuildMergedMessage(entry, keptAssistantUuids));
}
function buildSegmentId(participantKey: string, slices: StreamSlice[]): string {
const first = slices[0];
const last = slices[slices.length - 1];
return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`;
}
export class BoardTaskLogStreamService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
) {}
async getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse> {
if (!isBoardTaskExactLogsReadEnabled()) {
return emptyResponse();
}
const records = await this.recordSource.getTaskRecords(teamName, taskId);
if (records.length === 0) {
return emptyResponse();
}
const fileVersionsByPath = await getBoardTaskExactLogFileVersions(
records.map((record) => record.source.filePath)
);
const candidates = this.summarySelector
.selectSummaries({
records,
fileVersionsByPath,
})
.filter((candidate) => candidate.canLoadDetail)
.sort(compareCandidates);
if (candidates.length === 0) {
return emptyResponse();
}
const parsedMessagesByFile = await this.strictParser.parseFiles(
candidates.map((candidate) => candidate.source.filePath)
);
const slices: StreamSlice[] = [];
for (const candidate of candidates) {
const detail = this.detailSelector.selectDetail({
candidate,
records,
parsedMessagesByFile,
});
if (!detail || detail.filteredMessages.length === 0) {
continue;
}
const filteredMessages =
candidate.anchor.kind === 'tool'
? pruneToolAnchoredAssistantOutputMessages(
detail.filteredMessages,
candidate.anchor.toolUseId
)
: detail.filteredMessages;
const sanitizedMessages = sanitizeJsonLikeToolResultPayloads(
filteredMessages,
candidate.canonicalToolName
);
const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages);
if (prunedMessages.length === 0) {
continue;
}
const actor = toStreamActor(detail.actor);
slices.push({
id: detail.id,
timestamp: detail.timestamp,
filePath: detail.source.filePath,
participantKey: buildParticipantKey(actor),
actor,
actionCategory: candidate.actionCategory,
filteredMessages: prunedMessages,
});
}
if (slices.length === 0) {
return emptyResponse();
}
const deNoisedSlices = filterReadOnlySlices(slices);
const namedParticipantSlices = deNoisedSlices.filter((slice) =>
hasNamedParticipant(slice.actor)
);
const visibleSlices =
namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices;
const participantsByKey = new Map<string, BoardTaskLogParticipant>();
const participantOrder: string[] = [];
for (const slice of visibleSlices) {
if (participantsByKey.has(slice.participantKey)) {
continue;
}
participantsByKey.set(
slice.participantKey,
buildParticipant(slice.actor, slice.participantKey)
);
participantOrder.push(slice.participantKey);
}
const orderedParticipants = participantOrder
.map((key) => participantsByKey.get(key))
.filter((participant): participant is BoardTaskLogParticipant => Boolean(participant))
.sort((left, right) => {
if (left.isLead && !right.isLead) return 1;
if (!left.isLead && right.isLead) return -1;
return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key);
});
const segments: BoardTaskLogSegment[] = [];
let currentSegmentSlices: StreamSlice[] = [];
const flushSegment = (): void => {
if (currentSegmentSlices.length === 0) return;
const participantKey = currentSegmentSlices[0]!.participantKey;
const actor = currentSegmentSlices[0]!.actor;
const mergedMessages = mergeMessages(
currentSegmentSlices.map((slice) => ({
filePath: slice.filePath,
filteredMessages: slice.filteredMessages,
}))
);
const cleanedMessages = pruneEmptyInternalToolResultMessages(mergedMessages);
if (cleanedMessages.length === 0) {
currentSegmentSlices = [];
return;
}
const chunks = this.chunkBuilder.buildBundleChunks(cleanedMessages);
if (chunks.length > 0) {
segments.push({
id: buildSegmentId(participantKey, currentSegmentSlices),
participantKey,
actor,
startTimestamp: currentSegmentSlices[0]!.timestamp,
endTimestamp: currentSegmentSlices[currentSegmentSlices.length - 1]!.timestamp,
chunks,
});
}
currentSegmentSlices = [];
};
for (const slice of visibleSlices) {
if (
currentSegmentSlices.length > 0 &&
currentSegmentSlices[0]!.participantKey !== slice.participantKey
) {
flushSegment();
}
currentSegmentSlices.push(slice);
}
flushSegment();
const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead);
const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0]!.key : 'all';
return {
participants: orderedParticipants,
defaultFilter,
segments,
};
}
}

View file

@ -301,6 +301,18 @@ export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs';
/** Get session logs that reference a task */
export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask';
/** Get explicit board-task activity derived from transcript metadata */
export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity';
/** Get one task-scoped log stream derived from explicit board-task activity */
export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream';
/** Get exact task-log summaries derived from explicit board-task activity records */
export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries';
/** Get one exact task-log detail bundle for renderer reuse */
export const TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail';
/** Update team config (name, description) */
export const TEAM_UPDATE_CONFIG = 'team:updateConfig';

View file

@ -8,12 +8,11 @@ import {
API_KEYS_SAVE,
API_KEYS_STORAGE_STATUS,
APP_RELAUNCH,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_GET_PROVIDER_STATUS,
CLI_INSTALLER_GET_STATUS,
CLI_INSTALLER_INSTALL,
CLI_INSTALLER_INVALIDATE_STATUS,
CLI_INSTALLER_PROGRESS,
TMUX_GET_STATUS,
CONTEXT_CHANGED,
CONTEXT_GET_ACTIVE,
CONTEXT_LIST,
@ -125,8 +124,13 @@ import {
TEAM_GET_DATA,
TEAM_GET_DELETED_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_TASK_ACTIVITY,
TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_GET_MESSAGES_PAGE,
TEAM_GET_PROJECT_BRANCH,
TEAM_GET_SAVED_REQUEST,
TEAM_GET_TASK_ATTACHMENT,
@ -152,7 +156,6 @@ import {
TEAM_RESTORE_TASK,
TEAM_SAVE_TASK_ATTACHMENT,
TEAM_SEND_MESSAGE,
TEAM_GET_MESSAGES_PAGE,
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_SET_PROJECT_BRANCH_TRACKING,
TEAM_SET_TASK_CLARIFICATION,
@ -180,6 +183,7 @@ import {
TERMINAL_RESIZE,
TERMINAL_SPAWN,
TERMINAL_WRITE,
TMUX_GET_STATUS,
UPDATER_CHECK,
UPDATER_DOWNLOAD,
UPDATER_INSTALL,
@ -228,6 +232,10 @@ import type {
ApplyReviewRequest,
ApplyReviewResult,
AttachmentFileData,
BoardTaskActivityEntry,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
ChangeStats,
ClaudeRootFolderSelection,
ClaudeRootInfo,
@ -252,6 +260,7 @@ import type {
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusesSnapshot,
MessagesPage,
NotificationTrigger,
ProjectBranchChangeEvent,
RejectResult,
@ -261,7 +270,6 @@ import type {
ScheduleRun,
SendMessageRequest,
SendMessageResult,
MessagesPage,
SessionsByIdsOptions,
SessionsPaginationOptions,
SnippetDiff,
@ -290,10 +298,10 @@ import type {
TeamTask,
TeamTaskStatus,
TeamUpdateConfigRequest,
TmuxStatus,
ToolApprovalEvent,
ToolApprovalFileContent,
ToolApprovalSettings,
TmuxStatus,
TriggerTestResult,
UpdateKanbanPatch,
UpdateSchedulePatch,
@ -954,6 +962,41 @@ const electronAPI: ElectronAPI = {
options
);
},
getTaskActivity: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskActivityEntry[]>(
TEAM_GET_TASK_ACTIVITY,
teamName,
taskId
);
},
getTaskLogStream: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskLogStreamResponse>(
TEAM_GET_TASK_LOG_STREAM,
teamName,
taskId
);
},
getTaskExactLogSummaries: async (teamName: string, taskId: string) => {
return invokeIpcWithResult<BoardTaskExactLogSummariesResponse>(
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
teamName,
taskId
);
},
getTaskExactLogDetail: async (
teamName: string,
taskId: string,
exactLogId: string,
expectedSourceGeneration: string
) => {
return invokeIpcWithResult<BoardTaskExactLogDetailResult>(
TEAM_GET_TASK_EXACT_LOG_DETAIL,
teamName,
taskId,
exactLogId,
expectedSourceGeneration
);
},
getMemberStats: async (teamName: string, memberName: string) => {
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
},

View file

@ -9,6 +9,9 @@
import type {
AppConfig,
AttachmentFileData,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
ClaudeMdFileInfo,
ClaudeRootFolderSelection,
ClaudeRootInfo,
@ -804,6 +807,26 @@ export class HttpAPIClient implements ElectronAPI {
getLogsForTask: async () => {
return [];
},
getTaskActivity: async () => {
console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode');
return [];
},
getTaskLogStream: async (): Promise<BoardTaskLogStreamResponse> => {
console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode');
return {
participants: [],
defaultFilter: 'all',
segments: [],
};
},
getTaskExactLogSummaries: async (): Promise<BoardTaskExactLogSummariesResponse> => {
console.warn('[HttpAPIClient] getTaskExactLogSummaries is not available in browser mode');
return { items: [] };
},
getTaskExactLogDetail: async (): Promise<BoardTaskExactLogDetailResult> => {
console.warn('[HttpAPIClient] getTaskExactLogDetail is not available in browser mode');
return { status: 'missing' };
},
getMemberStats: async () => {
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
return {

View file

@ -9,7 +9,7 @@ import {
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
import { MemberBadge } from '@renderer/components/team/MemberBadge';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { TaskLogsPanel } from '@renderer/components/team/taskLogs/TaskLogsPanel';
import { Badge } from '@renderer/components/ui/badge';
import { Button } from '@renderer/components/ui/button';
import {
@ -1256,29 +1256,8 @@ export const TaskDetailDialog = ({
{variant === 'team' ? (
<CollapsibleTeamSection
key={`task-logs:${currentTask.id}`}
title="Execution Logs"
title="Task Logs"
icon={<ScrollText size={14} />}
headerExtra={
logsRefreshing || executionPreviewOnline ? (
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{executionPreviewOnline ? (
<span
className="pointer-events-none relative inline-flex size-2 shrink-0"
title="Online"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{logsRefreshing ? (
<span className="flex items-center gap-1">
<Loader2 size={10} className="animate-spin" />
Updating...
</span>
) : null}
</span>
) : null
}
contentClassName="pl-2.5 overflow-visible"
headerClassName="-mx-6 w-[calc(100%+3rem)]"
headerContentClassName="pl-6"
@ -1286,19 +1265,14 @@ export const TaskDetailDialog = ({
keepMounted
>
<div className="min-w-0">
<MemberLogsTab
<TaskLogsPanel
teamName={teamName}
taskId={currentTask.id}
taskOwner={currentTask.owner}
taskStatus={currentTask.status}
taskWorkIntervals={currentTask.workIntervals}
task={currentTask}
taskSince={taskSince}
isExecutionRefreshing={logsRefreshing}
isExecutionPreviewOnline={executionPreviewOnline}
onRefreshingChange={setLogsRefreshing}
// Only show a "latest messages" preview when this task is owned by a subagent.
// For lead-owned tasks, the lead session is a mixed stream (lead + multiple agents),
// so filtering to "just the member messages" is unreliable and easy to mislead.
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
// Temporary debug option: for lead-owned tasks, show quick preview from lead session.
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
onPreviewOnlineChange={setExecutionPreviewOnline}
/>

View file

@ -0,0 +1,132 @@
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { ChevronDown, ChevronRight, Clock, FileText, Loader2 } from 'lucide-react';
import type { BoardTaskExactLogSummary } from '@shared/types';
export interface ExactTaskLogDetailState {
status: 'idle' | 'loading' | 'ok' | 'missing' | 'error';
generation?: string;
chunks?: ReturnType<typeof asEnhancedChunkArray>;
error?: string;
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(summary: BoardTaskExactLogSummary): string {
if (summary.actor.memberName) {
return summary.actor.memberName;
}
if (summary.actor.role === 'lead' || summary.actor.isSidechain === false) {
return 'lead session';
}
return 'unknown actor';
}
function describeSummary(summary: BoardTaskExactLogSummary): string {
return summary.actionLabel;
}
function anchorKindLabel(summary: BoardTaskExactLogSummary): string {
return summary.anchorKind === 'tool' ? 'tool' : 'message';
}
function describeDetailState(state: ExactTaskLogDetailState | undefined): string | null {
if (!state) return null;
if (state.status === 'missing') {
return 'Exact detail is no longer available for this transcript slice.';
}
if (state.status === 'error') {
return state.error ?? 'Failed to load exact detail.';
}
return null;
}
interface ExactTaskLogCardProps {
summary: BoardTaskExactLogSummary;
expanded: boolean;
detailState?: ExactTaskLogDetailState;
onToggle: () => void;
}
export function ExactTaskLogCard({
summary,
expanded,
detailState,
onToggle,
}: ExactTaskLogCardProps): React.JSX.Element {
const loadStateText = describeDetailState(detailState);
return (
<div className="min-w-0 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]">
<button
type="button"
className="sticky -top-6 z-10 flex w-full min-w-0 items-center gap-2 overflow-hidden rounded-t-md border-b border-transparent bg-[var(--color-surface)] px-3 py-2 text-left text-xs hover:bg-[var(--color-surface-raised)] disabled:cursor-not-allowed disabled:opacity-70"
disabled={!summary.canLoadDetail}
onClick={onToggle}
aria-expanded={summary.canLoadDetail ? expanded : undefined}
>
{summary.canLoadDetail ? (
expanded ? (
<ChevronDown size={12} className="shrink-0 text-[var(--color-text-muted)]" />
) : (
<ChevronRight size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)
) : (
<FileText size={12} className="shrink-0 text-[var(--color-text-muted)]" />
)}
<div className="min-w-0 flex-1 overflow-hidden">
<div className="flex items-center gap-1.5">
<span className="truncate font-medium text-[var(--color-text)]">
{actorLabel(summary)}
</span>
<span className="text-[var(--color-text-muted)]">-</span>
<span className="truncate text-[var(--color-text)]">{describeSummary(summary)}</span>
</div>
<div className="mt-0.5 flex items-center gap-3 text-[10px] text-[var(--color-text-muted)]">
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(summary.timestamp)}
</span>
<span>{anchorKindLabel(summary)}</span>
{!summary.canLoadDetail ? <span>summary only</span> : null}
</div>
</div>
</button>
{expanded ? (
<div className="border-t border-[var(--color-border)] px-3 py-2">
{detailState?.status === 'loading' ? (
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading exact task logs...
</div>
) : null}
{detailState?.status === 'ok' && detailState.chunks ? (
<div className="w-full min-w-0">
<MemberExecutionLog
chunks={detailState.chunks}
memberName={summary.actor.isSidechain ? summary.actor.memberName : undefined}
/>
</div>
) : null}
{detailState?.status !== 'loading' && loadStateText ? (
<div className="py-4 text-xs text-[var(--color-text-muted)]">{loadStateText}</div>
) : null}
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,262 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { api } from '@renderer/api';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { AlertCircle, FileText, Loader2 } from 'lucide-react';
import { ExactTaskLogCard, type ExactTaskLogDetailState } from './ExactTaskLogCard';
import type { BoardTaskExactLogSummary } from '@shared/types';
interface ExactTaskLogsSectionProps {
teamName: string;
taskId: string;
}
export function ExactTaskLogsSection({
teamName,
taskId,
}: ExactTaskLogsSectionProps): React.JSX.Element {
const [summaries, setSummaries] = useState<BoardTaskExactLogSummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [expandedId, setExpandedId] = useState<string | null>(null);
const [detailStates, setDetailStates] = useState<Record<string, ExactTaskLogDetailState>>({});
const latestRequestSeqById = useRef<Record<string, number>>({});
const loadSummaries = useCallback(async (): Promise<BoardTaskExactLogSummary[]> => {
const result = await api.teams.getTaskExactLogSummaries(teamName, taskId);
const nextItems = [...result.items].sort((left, right) => {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return rightTs - leftTs;
}
if (left.source.filePath !== right.source.filePath) {
return left.source.filePath.localeCompare(right.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
return left.id.localeCompare(right.id);
});
setSummaries(nextItems);
return nextItems;
}, [taskId, teamName]);
useEffect(() => {
let cancelled = false;
const run = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
setExpandedId(null);
setDetailStates({});
latestRequestSeqById.current = {};
const nextItems = await api.teams.getTaskExactLogSummaries(teamName, taskId);
if (cancelled) return;
setSummaries(
[...nextItems.items].sort((left, right) => {
const leftTs = Date.parse(left.timestamp);
const rightTs = Date.parse(right.timestamp);
if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) {
return rightTs - leftTs;
}
if (left.source.filePath !== right.source.filePath) {
return left.source.filePath.localeCompare(right.source.filePath);
}
if (left.source.sourceOrder !== right.source.sourceOrder) {
return left.source.sourceOrder - right.source.sourceOrder;
}
return left.id.localeCompare(right.id);
})
);
} catch (loadError) {
if (!cancelled) {
setError(
loadError instanceof Error ? loadError.message : 'Failed to load exact task logs'
);
setSummaries([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [taskId, teamName]);
const fetchDetail = useCallback(
async (
summary: Extract<BoardTaskExactLogSummary, { canLoadDetail: true }>,
retryOnStale: boolean
): Promise<void> => {
const nextSeq = (latestRequestSeqById.current[summary.id] ?? 0) + 1;
latestRequestSeqById.current[summary.id] = nextSeq;
setDetailStates((prev) => ({
...prev,
[summary.id]: {
status: 'loading',
generation: summary.sourceGeneration,
},
}));
try {
const result = await api.teams.getTaskExactLogDetail(
teamName,
taskId,
summary.id,
summary.sourceGeneration
);
if (latestRequestSeqById.current[summary.id] !== nextSeq) {
return;
}
if (result.status === 'stale' && retryOnStale) {
const refreshed = await loadSummaries();
const refreshedSummary = refreshed.find(
(item): item is Extract<BoardTaskExactLogSummary, { canLoadDetail: true }> =>
item.id === summary.id && item.canLoadDetail
);
if (!refreshedSummary) {
setDetailStates((prev) => ({
...prev,
[summary.id]: { status: 'missing' },
}));
return;
}
await fetchDetail(refreshedSummary, false);
return;
}
if (result.status === 'ok') {
setDetailStates((prev) => ({
...prev,
[summary.id]: {
status: 'ok',
generation: summary.sourceGeneration,
chunks: asEnhancedChunkArray(result.detail.chunks),
},
}));
return;
}
setDetailStates((prev) => ({
...prev,
[summary.id]: { status: 'missing', generation: summary.sourceGeneration },
}));
} catch (detailError) {
if (latestRequestSeqById.current[summary.id] !== nextSeq) {
return;
}
setDetailStates((prev) => ({
...prev,
[summary.id]: {
status: 'error',
generation: summary.sourceGeneration,
error:
detailError instanceof Error ? detailError.message : 'Failed to load exact task logs',
},
}));
}
},
[loadSummaries, taskId, teamName]
);
const handleToggle = useCallback(
async (summary: BoardTaskExactLogSummary): Promise<void> => {
if (!summary.canLoadDetail) {
return;
}
if (expandedId === summary.id) {
setExpandedId(null);
return;
}
setExpandedId(summary.id);
const existing = detailStates[summary.id];
if (existing?.generation === summary.sourceGeneration && existing.status !== 'error') {
return;
}
await fetchDetail(summary, true);
},
[detailStates, expandedId, fetchDetail]
);
const visibleSummaries = useMemo(() => summaries, [summaries]);
if (loading && visibleSummaries.length === 0) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Exact Task Logs
</h4>
</div>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading exact task logs...
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Exact Task Logs
</h4>
</div>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Exact Task Logs
</h4>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Exact transcript slices rendered with the same execution-log components used in Logs.
</p>
{visibleSummaries.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
No exact task logs yet
<p className="mt-1 text-[10px] opacity-60">
Exact transcript bundles will appear here when explicit task-linked transcript metadata
is available.
</p>
</div>
) : (
<div className="w-full min-w-0 space-y-1.5">
{visibleSummaries.map((summary) => (
<ExactTaskLogCard
key={summary.id}
summary={summary}
expanded={expandedId === summary.id}
detailState={detailStates[summary.id]}
onToggle={() => void handleToggle(summary)}
/>
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,48 @@
import type { ComponentProps } from 'react';
import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab';
import { Loader2 } from 'lucide-react';
interface ExecutionSessionsSectionProps extends ComponentProps<typeof MemberLogsTab> {
isRefreshing?: boolean;
isPreviewOnline?: boolean;
}
export function ExecutionSessionsSection({
isRefreshing = false,
isPreviewOnline = false,
...props
}: ExecutionSessionsSectionProps): React.JSX.Element {
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Execution Sessions
</h4>
{isRefreshing || isPreviewOnline ? (
<span className="flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
{isPreviewOnline ? (
<span
className="pointer-events-none relative inline-flex size-2 shrink-0"
title="Online"
>
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-50" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
) : null}
{isRefreshing ? (
<span className="flex items-center gap-1">
<Loader2 size={10} className="animate-spin" />
Updating...
</span>
) : null}
</span>
) : null}
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Legacy session-centric transcript browsing and previews.
</p>
<MemberLogsTab {...props} />
</div>
);
}

View file

@ -0,0 +1,211 @@
import { api } from '@renderer/api';
import { AlertCircle, Loader2 } from 'lucide-react';
import { useEffect, useMemo, useState } from 'react';
import {
describeBoardTaskActivityLabel,
formatBoardTaskActivityTaskLabel,
} from '@shared/utils/boardTaskActivityLabels';
import type { BoardTaskActivityEntry, BoardTaskActivityTaskRef } from '@shared/types';
interface TaskActivitySectionProps {
teamName: string;
taskId: string;
}
function formatEntryTime(timestamp: string): string {
const date = new Date(timestamp);
if (Number.isNaN(date.getTime())) {
return '--:--';
}
return date.toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
});
}
function formatTaskLabel(task: BoardTaskActivityTaskRef | undefined): string | null {
return formatBoardTaskActivityTaskLabel(task);
}
function relationshipContextLabel(entry: BoardTaskActivityEntry): string | null {
const peerTaskLabel = formatTaskLabel(entry.action?.peerTask);
if (!peerTaskLabel) return null;
switch (entry.action?.relationshipPerspective) {
case 'incoming':
return `from ${peerTaskLabel}`;
case 'outgoing':
return `to ${peerTaskLabel}`;
default:
return `with ${peerTaskLabel}`;
}
}
function describeContext(entry: BoardTaskActivityEntry): string | null {
const parts: string[] = [];
const relationshipContext = relationshipContextLabel(entry);
if (relationshipContext) {
parts.push(relationshipContext);
}
if (entry.actorContext.relation === 'other_active_task') {
const activeTaskLabel = formatTaskLabel(entry.actorContext.activeTask);
if (activeTaskLabel) {
parts.push(`while working on ${activeTaskLabel}`);
} else {
parts.push('while another task was active');
}
} else if (entry.actorContext.relation === 'ambiguous') {
parts.push('while multiple task scopes were active');
} else if (entry.actorContext.relation === 'idle' && entry.linkKind !== 'execution') {
parts.push('without an active task scope');
}
if (entry.task.resolution === 'deleted') {
parts.push('task is deleted');
} else if (entry.task.resolution === 'ambiguous') {
parts.push('task resolution is ambiguous');
} else if (entry.task.resolution === 'unresolved') {
parts.push('task could not be resolved');
}
return parts.length > 0 ? parts.join(' - ') : null;
}
function actorLabel(entry: BoardTaskActivityEntry): string {
if (entry.actor.memberName) {
return entry.actor.memberName;
}
if (entry.actor.role === 'lead' || entry.actor.isSidechain === false) {
return 'lead session';
}
return 'unknown actor';
}
function Row({ entry }: { entry: BoardTaskActivityEntry }): React.JSX.Element {
const context = describeContext(entry);
const tone =
entry.task.resolution === 'resolved'
? 'text-[var(--color-text)]'
: 'text-[var(--color-text-muted)]';
return (
<div className="border-[var(--color-border-muted)]/60 bg-[var(--color-bg-elevated)]/40 rounded-md border px-3 py-2">
<div className="flex items-start gap-3">
<div className="min-w-12 pt-0.5 text-[10px] font-medium uppercase tracking-[0.16em] text-[var(--color-text-muted)]">
{formatEntryTime(entry.timestamp)}
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm ${tone}`}>
<span className="font-medium">{actorLabel(entry)}</span>
<span className="text-[var(--color-text-muted)]"> - </span>
<span>{describeBoardTaskActivityLabel(entry)}</span>
</div>
{context ? (
<p className="mt-1 text-xs text-[var(--color-text-muted)]">{context}</p>
) : null}
</div>
</div>
</div>
);
}
export function TaskActivitySection({
teamName,
taskId,
}: TaskActivitySectionProps): React.JSX.Element {
const [entries, setEntries] = useState<BoardTaskActivityEntry[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let cancelled = false;
const load = async (): Promise<void> => {
try {
if (!cancelled && entries.length === 0) {
setLoading(true);
}
if (!cancelled) {
setError(null);
}
const result = await api.teams.getTaskActivity(teamName, taskId);
if (!cancelled) {
setEntries(result);
}
} catch (loadError) {
if (!cancelled) {
setError(loadError instanceof Error ? loadError.message : 'Failed to load task activity');
setEntries([]);
}
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void load();
const intervalId = window.setInterval(() => {
void load();
}, 8000);
return () => {
cancelled = true;
window.clearInterval(intervalId);
};
}, [entries.length, teamName, taskId]);
const content = useMemo(() => {
if (loading) {
return (
<div className="flex items-center gap-2 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading task activity...
</div>
);
}
if (error) {
return (
<div className="flex items-center gap-2 rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-300">
<AlertCircle size={12} />
{error}
</div>
);
}
if (entries.length === 0) {
return (
<p className="text-xs text-[var(--color-text-muted)]">
No explicit task activity was found in the available transcripts yet. Older or heuristic
session logs may still be available below in Execution Sessions.
</p>
);
}
return (
<div className="space-y-2">
{entries.map((entry) => (
<Row key={entry.id} entry={entry} />
))}
</div>
);
}, [entries, error, loading]);
return (
<div className="space-y-2">
<div className="flex items-center gap-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Activity
</h4>
</div>
<p className="text-xs text-[var(--color-text-muted)]">
Explicit runtime activity linked to this task from transcript metadata.
</p>
{content}
</div>
);
}

View file

@ -0,0 +1,222 @@
import { useEffect, useMemo, useState } from 'react';
import { api } from '@renderer/api';
import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog';
import { asEnhancedChunkArray } from '@renderer/types/data';
import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react';
import type {
BoardTaskLogActor,
BoardTaskLogSegment,
BoardTaskLogStreamResponse,
} from '@shared/types';
interface TaskLogStreamSectionProps {
teamName: string;
taskId: string;
}
function formatRelativeTime(isoString: string): string {
const date = new Date(isoString);
const diffMs = Date.now() - date.getTime();
const diffMin = Math.floor(diffMs / 60_000);
const diffHours = Math.floor(diffMin / 60);
const diffDays = Math.floor(diffHours / 24);
if (!Number.isFinite(diffMs)) return '--';
if (diffMin < 1) return 'just now';
if (diffMin < 60) return `${diffMin}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
return `${diffDays}d ago`;
}
function actorLabel(actor: BoardTaskLogActor): string {
if (actor.memberName) {
return actor.memberName;
}
if (actor.role === 'lead' || actor.isSidechain === false) {
return 'lead session';
}
if (actor.agentId) {
return `member ${actor.agentId.slice(0, 8)}`;
}
return `member session ${actor.sessionId.slice(0, 8)}`;
}
function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse {
return {
participants: response.participants,
defaultFilter: response.defaultFilter,
segments: response.segments.map((segment) => ({
...segment,
chunks: asEnhancedChunkArray(segment.chunks) ?? [],
})),
};
}
function SegmentMarker({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element {
return (
<div className="mb-2 flex items-center gap-2 text-[10px] text-[var(--color-text-muted)]">
<span className="rounded-full border border-[var(--color-border)] px-2 py-0.5 font-medium text-[var(--color-text-secondary)]">
{actorLabel(segment.actor)}
</span>
<span className="flex items-center gap-1">
<Clock size={10} />
{formatRelativeTime(segment.endTimestamp)}
</span>
</div>
);
}
function SegmentBlock({
segment,
showHeader,
}: {
segment: BoardTaskLogSegment;
showHeader: boolean;
}): React.JSX.Element {
return (
<div className="min-w-0 overflow-hidden">
{showHeader ? <SegmentMarker segment={segment} /> : null}
<MemberExecutionLog chunks={segment.chunks} memberName={segment.actor.memberName} />
</div>
);
}
export function TaskLogStreamSection({
teamName,
taskId,
}: TaskLogStreamSectionProps): React.JSX.Element {
const [stream, setStream] = useState<BoardTaskLogStreamResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all');
useEffect(() => {
let cancelled = false;
const run = async (): Promise<void> => {
try {
setLoading(true);
setError(null);
const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId));
if (cancelled) return;
setStream(response);
setSelectedParticipantKey(response.defaultFilter);
} catch (loadError) {
if (cancelled) return;
setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream');
setStream(null);
} finally {
if (!cancelled) {
setLoading(false);
}
}
};
void run();
return () => {
cancelled = true;
};
}, [taskId, teamName]);
const participants = stream?.participants ?? [];
const showChips = participants.length > 1;
const visibleSegments = useMemo(() => {
const source = stream?.segments ?? [];
const filtered =
selectedParticipantKey === 'all'
? source
: source.filter((segment) => segment.participantKey === selectedParticipantKey);
return [...filtered].reverse();
}, [selectedParticipantKey, stream?.segments]);
const showSegmentHeaders =
participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1);
if (loading) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-[var(--color-text-muted)]">
<Loader2 size={12} className="animate-spin" />
Loading task log stream...
</div>
</div>
);
}
if (error) {
return (
<div className="space-y-2">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<div className="flex items-center gap-2 py-4 text-xs text-red-400">
<AlertCircle size={14} />
{error}
</div>
</div>
);
}
return (
<div className="space-y-3">
<h4 className="text-xs font-semibold uppercase tracking-[0.2em] text-[var(--color-text-muted)]">
Task Log Stream
</h4>
<p className="text-xs text-[var(--color-text-muted)]">
Task-scoped transcript logs rendered with the same execution-log components used in Logs.
</p>
{showChips ? (
<div className="flex flex-wrap items-center gap-1.5">
<button
type="button"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
selectedParticipantKey === 'all'
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey('all')}
>
All
</button>
{participants.map((participant) => (
<button
key={participant.key}
type="button"
className={`rounded-full border px-2.5 py-1 text-[11px] transition-colors ${
selectedParticipantKey === participant.key
? 'bg-[var(--color-accent)]/10 border-[var(--color-accent)] text-[var(--color-text)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:text-[var(--color-text)]'
}`}
onClick={() => setSelectedParticipantKey(participant.key)}
>
{participant.label}
</button>
))}
</div>
) : null}
{visibleSegments.length === 0 ? (
<div className="py-8 text-center text-xs text-[var(--color-text-muted)]">
<FileText size={20} className="mx-auto mb-2 opacity-40" />
No task log stream yet
<p className="mt-1 text-[10px] opacity-60">
Task-linked transcript logs will appear here when explicit task-linked transcript
metadata is available.
</p>
</div>
) : (
<div className="space-y-6">
{visibleSegments.map((segment) => (
<SegmentBlock key={segment.id} segment={segment} showHeader={showSegmentHeaders} />
))}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,55 @@
import { ExecutionSessionsSection } from './ExecutionSessionsSection';
import { TaskActivitySection } from './TaskActivitySection';
import { TaskLogStreamSection } from './TaskLogStreamSection';
import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates';
import type { TeamTaskWithKanban } from '@shared/types';
interface TaskLogsPanelProps {
teamName: string;
task: TeamTaskWithKanban;
taskSince?: string;
isExecutionRefreshing?: boolean;
isExecutionPreviewOnline?: boolean;
onRefreshingChange?: (isRefreshing: boolean) => void;
showSubagentPreview?: boolean;
showLeadPreview?: boolean;
onPreviewOnlineChange?: (isOnline: boolean) => void;
}
export function TaskLogsPanel({
teamName,
task,
taskSince,
isExecutionRefreshing = false,
isExecutionPreviewOnline = false,
onRefreshingChange,
showSubagentPreview = false,
showLeadPreview = false,
onPreviewOnlineChange,
}: TaskLogsPanelProps): React.JSX.Element {
return (
<div className="space-y-4">
{isBoardTaskActivityUiEnabled() ? (
<TaskActivitySection teamName={teamName} taskId={task.id} />
) : null}
{isBoardTaskExactLogsUiEnabled() ? (
<TaskLogStreamSection teamName={teamName} taskId={task.id} />
) : null}
<ExecutionSessionsSection
teamName={teamName}
taskId={task.id}
taskOwner={task.owner}
taskStatus={task.status}
taskWorkIntervals={task.workIntervals}
taskSince={taskSince}
isRefreshing={isExecutionRefreshing}
isPreviewOnline={isExecutionPreviewOnline}
onRefreshingChange={onRefreshingChange}
showSubagentPreview={showSubagentPreview}
showLeadPreview={showLeadPreview}
onPreviewOnlineChange={onPreviewOnlineChange}
/>
</div>
);
}

View file

@ -0,0 +1,22 @@
function readEnabledFlag(value: unknown, defaultValue: boolean): boolean {
if (typeof value !== 'string') {
return defaultValue;
}
const normalized = value.trim().toLowerCase();
if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') {
return false;
}
if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') {
return true;
}
return defaultValue;
}
export function isBoardTaskActivityUiEnabled(): boolean {
return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_ACTIVITY_UI_ENABLED, true);
}
export function isBoardTaskExactLogsUiEnabled(): boolean {
return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED, true);
}

View file

@ -38,6 +38,10 @@ import type {
} from './schedule';
import type {
AddMemberRequest,
BoardTaskActivityEntry,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
AddTaskCommentRequest,
AttachmentFileData,
CreateTaskRequest,
@ -51,11 +55,11 @@ import type {
MemberFullStats,
MemberLogSummary,
MemberSpawnStatusesSnapshot,
MessagesPage,
ProjectBranchChangeEvent,
ReplaceMembersRequest,
SendMessageRequest,
SendMessageResult,
MessagesPage,
TaskAttachmentMeta,
TaskChangePresenceState,
TaskComment,
@ -477,6 +481,18 @@ export interface TeamsAPI {
since?: string;
}
) => Promise<MemberLogSummary[]>;
getTaskActivity: (teamName: string, taskId: string) => Promise<BoardTaskActivityEntry[]>;
getTaskLogStream: (teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>;
getTaskExactLogSummaries: (
teamName: string,
taskId: string
) => Promise<BoardTaskExactLogSummariesResponse>;
getTaskExactLogDetail: (
teamName: string,
taskId: string,
exactLogId: string,
expectedSourceGeneration: string
) => Promise<BoardTaskExactLogDetailResult>;
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
getAllTasks: () => Promise<GlobalTask[]>;

View file

@ -0,0 +1,128 @@
import type {
BoardTaskActivityAction,
BoardTaskActivityLinkKind,
BoardTaskActivityTaskRef,
} from '../types/team';
interface BoardTaskActivityLabelInput {
action?: BoardTaskActivityAction;
linkKind: BoardTaskActivityLinkKind;
}
export function formatBoardTaskActivityTaskLabel(
task: BoardTaskActivityTaskRef | undefined
): string | null {
if (!task) return null;
if (task.taskRef) {
return `#${task.taskRef.displayId}`;
}
if (task.locator.ref) {
return `#${task.locator.ref}`;
}
return null;
}
function describeRelationshipAction(
action: BoardTaskActivityAction | undefined,
verb: 'link' | 'unlink'
): string {
const peerTaskLabel = formatBoardTaskActivityTaskLabel(action?.peerTask);
const relationship = action?.details?.relationship;
if (relationship === 'related' && peerTaskLabel) {
return verb === 'link'
? `Linked related task ${peerTaskLabel}`
: `Removed related link with ${peerTaskLabel}`;
}
if (action?.relationshipPerspective === 'incoming' && peerTaskLabel) {
return verb === 'link'
? `Linked blocked by ${peerTaskLabel}`
: `Removed blocked-by link from ${peerTaskLabel}`;
}
if (action?.relationshipPerspective === 'outgoing' && peerTaskLabel) {
return verb === 'link'
? `Linked blocks ${peerTaskLabel}`
: `Removed blocks link to ${peerTaskLabel}`;
}
if (relationship) {
return verb === 'link' ? `Linked task as ${relationship}` : `Removed ${relationship} link`;
}
return verb === 'link' ? 'Linked task' : 'Removed task link';
}
export function describeBoardTaskActivityLabel(input: BoardTaskActivityLabelInput): string {
const toolName = input.action?.canonicalToolName;
switch (toolName) {
case 'task_start':
return 'Started work';
case 'task_complete':
return 'Completed task';
case 'task_set_status':
return input.action?.details?.status
? `Set status to ${input.action.details.status}`
: 'Updated task status';
case 'review_start':
return 'Started review';
case 'review_approve':
return 'Approved review';
case 'review_request_changes':
return 'Requested changes';
case 'review_request':
return input.action?.details?.reviewer
? `Requested review from ${input.action.details.reviewer}`
: 'Requested review';
case 'task_add_comment':
return 'Added a comment';
case 'task_attach_file':
return input.action?.details?.filename
? `Attached ${input.action.details.filename}`
: 'Attached a file';
case 'task_attach_comment_file':
return input.action?.details?.filename
? `Attached ${input.action.details.filename} to a comment`
: 'Attached a file to a comment';
case 'task_get':
return 'Viewed task';
case 'task_get_comment':
return input.action?.details?.commentId
? `Viewed comment ${input.action.details.commentId}`
: 'Viewed comment';
case 'task_link':
return describeRelationshipAction(input.action, 'link');
case 'task_unlink':
return describeRelationshipAction(input.action, 'unlink');
case 'task_set_clarification':
if (
input.action?.details?.clarification === 'lead' ||
input.action?.details?.clarification === 'user'
) {
return `Set clarification to ${input.action.details.clarification}`;
}
if (input.action?.details && 'clarification' in input.action.details) {
return 'Cleared clarification';
}
return 'Updated clarification';
case 'task_set_owner':
if (typeof input.action?.details?.owner === 'string' && input.action.details.owner.trim()) {
return `Assigned owner to ${input.action.details.owner}`;
}
if (input.action?.details && 'owner' in input.action.details) {
return 'Cleared owner';
}
return 'Updated owner';
case 'kanban_set_column':
return 'Updated column';
default:
if (input.linkKind === 'execution') {
return 'Worked on task';
}
if (input.linkKind === 'lifecycle') {
return 'Updated task lifecycle';
}
return 'Performed a related board action';
}
}

View file

@ -1,6 +1,14 @@
import * as os from 'os';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team';
import type {
BoardTaskActivityEntry,
BoardTaskLogStreamResponse,
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
InboxMessage,
TeamCreateRequest,
TeamProvisioningProgress,
} from '@shared/types/team';
vi.mock('electron', () => ({
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
@ -64,6 +72,10 @@ import {
TEAM_SET_CHANGE_PRESENCE_TRACKING,
TEAM_GET_ALL_TASKS,
TEAM_GET_LOGS_FOR_TASK,
TEAM_GET_TASK_ACTIVITY,
TEAM_GET_TASK_LOG_STREAM,
TEAM_GET_TASK_EXACT_LOG_DETAIL,
TEAM_GET_TASK_EXACT_LOG_SUMMARIES,
TEAM_GET_MEMBER_LOGS,
TEAM_GET_MEMBER_STATS,
TEAM_START_TASK,
@ -186,6 +198,25 @@ describe('ipc teams handlers', () => {
getLeadActivityState: vi.fn(() => 'idle'),
stopTeam: vi.fn(() => undefined),
};
const boardTaskActivityService = {
getTaskActivity: vi.fn<() => Promise<BoardTaskActivityEntry[]>>(async () => []),
};
const boardTaskLogStreamService = {
getTaskLogStream:
vi.fn<() => Promise<BoardTaskLogStreamResponse>>(async () => ({
participants: [],
defaultFilter: 'all',
segments: [],
})),
};
const boardTaskExactLogsService = {
getTaskExactLogSummaries:
vi.fn<() => Promise<BoardTaskExactLogSummariesResponse>>(async () => ({ items: [] })),
};
const boardTaskExactLogDetailService = {
getTaskExactLogDetail:
vi.fn<() => Promise<BoardTaskExactLogDetailResult>>(async () => ({ status: 'missing' })),
};
beforeEach(() => {
handlers.clear();
@ -195,7 +226,19 @@ describe('ipc teams handlers', () => {
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
mockTeamDataWorkerClient.getTeamData.mockReset();
mockTeamDataWorkerClient.findLogsForTask.mockReset();
initializeTeamHandlers(service as never, provisioningService as never);
initializeTeamHandlers(
service as never,
provisioningService as never,
undefined,
undefined,
undefined,
undefined,
undefined,
boardTaskActivityService as never,
boardTaskLogStreamService as never,
boardTaskExactLogsService as never,
boardTaskExactLogDetailService as never,
);
registerTeamHandlers(ipcMain as never);
});
@ -224,6 +267,10 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_SUMMARIES)).toBe(true);
expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_DETAIL)).toBe(true);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true);
@ -279,6 +326,149 @@ describe('ipc teams handlers', () => {
expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team');
});
it('returns explicit exact task-log summaries for a task', async () => {
boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({
items: [
{
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
anchorKind: 'tool',
actionLabel: 'Added a comment',
actionCategory: 'comment',
canonicalToolName: 'task_add_comment',
linkKinds: ['board_action'],
canLoadDetail: true,
sourceGeneration: 'gen-1',
},
],
});
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_SUMMARIES);
expect(handler).toBeDefined();
const result = (await handler!(
{} as never,
'my-team',
'123e4567-e89b-12d3-a456-426614174000'
)) as {
success: boolean;
data?: BoardTaskExactLogSummariesResponse;
};
expect(result.success).toBe(true);
expect(result.data?.items).toHaveLength(1);
expect(boardTaskExactLogsService.getTaskExactLogSummaries).toHaveBeenCalledWith(
'my-team',
'123e4567-e89b-12d3-a456-426614174000'
);
});
it('returns one task log stream for a task', async () => {
boardTaskLogStreamService.getTaskLogStream.mockResolvedValueOnce({
participants: [
{
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: true,
},
],
defaultFilter: 'all',
segments: [],
});
const handler = handlers.get(TEAM_GET_TASK_LOG_STREAM);
expect(handler).toBeDefined();
const result = (await handler!(
{} as never,
'my-team',
'123e4567-e89b-12d3-a456-426614174000'
)) as {
success: boolean;
data?: BoardTaskLogStreamResponse;
};
expect(result.success).toBe(true);
expect(result.data?.participants).toHaveLength(1);
expect(boardTaskLogStreamService.getTaskLogStream).toHaveBeenCalledWith(
'my-team',
'123e4567-e89b-12d3-a456-426614174000'
);
});
it('returns exact task-log detail for a task bundle', async () => {
boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({
status: 'ok',
detail: {
id: 'tool:/tmp/task.jsonl:tool-1',
chunks: [],
},
});
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL);
expect(handler).toBeDefined();
const result = (await handler!(
{} as never,
'my-team',
'123e4567-e89b-12d3-a456-426614174000',
'tool:/tmp/task.jsonl:tool-1',
'gen-1'
)) as {
success: boolean;
data?: BoardTaskExactLogDetailResult;
};
expect(result.success).toBe(true);
expect(result.data?.status).toBe('ok');
expect(boardTaskExactLogDetailService.getTaskExactLogDetail).toHaveBeenCalledWith(
'my-team',
'123e4567-e89b-12d3-a456-426614174000',
'tool:/tmp/task.jsonl:tool-1',
'gen-1'
);
});
it('returns exact task-log detail stale status without rewriting the service result', async () => {
boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({
status: 'stale',
});
const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL);
expect(handler).toBeDefined();
const result = (await handler!(
{} as never,
'my-team',
'123e4567-e89b-12d3-a456-426614174000',
'tool:/tmp/task.jsonl:tool-1',
'gen-2'
)) as {
success: boolean;
data?: BoardTaskExactLogDetailResult;
};
expect(result).toEqual({
success: true,
data: { status: 'stale' },
});
});
it('returns success false on invalid sendMessage args', async () => {
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
expect(sendHandler).toBeDefined();
@ -893,6 +1083,8 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false);
expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false);
expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(false);
expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(false);
expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false);
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false);
@ -922,6 +1114,46 @@ describe('ipc teams handlers', () => {
expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false);
});
it('returns explicit task activity rows', async () => {
const handler = handlers.get(TEAM_GET_TASK_ACTIVITY);
expect(handler).toBeDefined();
const activityRows: BoardTaskActivityEntry[] = [
{
id: 'activity-1',
timestamp: '2026-04-12T10:00:00.000Z',
task: {
locator: { ref: 'abcd1234', refKind: 'display' },
resolution: 'resolved',
},
linkKind: 'lifecycle',
targetRole: 'subject',
actor: {
role: 'lead',
sessionId: 'session-1',
isSidechain: false,
},
actorContext: {
relation: 'idle',
},
source: {
messageUuid: 'message-1',
filePath: '/tmp/transcript.jsonl',
sourceOrder: 1,
},
},
];
boardTaskActivityService.getTaskActivity.mockResolvedValueOnce(activityRows);
const result = (await handler!({} as never, 'my-team', 'task-1')) as {
success: boolean;
data: typeof activityRows;
};
expect(result).toEqual({ success: true, data: activityRows });
expect(boardTaskActivityService.getTaskActivity).toHaveBeenCalledWith('my-team', 'task-1');
});
describe('addTaskRelationship', () => {
it('calls service on valid input', async () => {
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;

View file

@ -0,0 +1,427 @@
import { describe, expect, it } from 'vitest';
import { BoardTaskActivityEntryBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder';
import type { TeamTask } from '../../../../src/shared/types/team';
import type { RawTaskActivityMessage } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
function makeTask(task: Partial<TeamTask> & Pick<TeamTask, 'id' | 'subject' | 'status'>): TeamTask {
return {
displayId: task.displayId ?? task.id.slice(0, 8),
createdAt: '2026-04-12T10:00:00.000Z',
updatedAt: '2026-04-12T10:00:00.000Z',
...task,
};
}
describe('BoardTaskActivityEntryBuilder', () => {
it('builds same-task execution rows and external board actions', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
});
const taskB = makeTask({
id: '123e4567-e89b-12d3-a456-426614174001',
displayId: 'efgh5678',
subject: 'Task B',
status: 'pending',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/a.jsonl',
uuid: 'msg-1',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
agentId: 'agent-a',
agentName: 'alice',
isSidechain: true,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
],
boardTaskToolActions: [],
},
{
filePath: '/tmp/b.jsonl',
uuid: 'msg-2',
timestamp: '2026-04-12T10:01:00.000Z',
sessionId: 'session-1',
agentId: 'agent-a',
agentName: 'alice',
isSidechain: true,
sourceOrder: 2,
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'tool-2',
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: {
relation: 'other_active_task',
activeTask: { ref: 'efgh5678', refKind: 'display', canonicalId: taskB.id },
activePhase: 'work',
activeExecutionSeq: 2,
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'tool-2',
canonicalToolName: 'task_add_comment',
resultRefs: { commentId: 'comment-1' },
},
],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA, taskB],
messages,
});
expect(entries).toHaveLength(2);
expect(entries[0]?.linkKind).toBe('execution');
expect(entries[1]?.actorContext.relation).toBe('other_active_task');
expect(entries[1]?.action?.canonicalToolName).toBe('task_add_comment');
expect(entries[1]?.action?.category).toBe('comment');
expect(entries[1]?.action?.details?.commentId).toBe('comment-1');
});
it('marks display-id collisions as ambiguous instead of guessing', () => {
const liveTask = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Live task',
status: 'in_progress',
});
const deletedTask = makeTask({
id: '123e4567-e89b-12d3-a456-426614174099',
displayId: 'abcd1234',
subject: 'Deleted task',
status: 'deleted',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/a.jsonl',
uuid: 'msg-1',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
isSidechain: true,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
],
boardTaskToolActions: [],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: liveTask,
tasks: [liveTask, deletedTask],
messages,
});
expect(entries).toHaveLength(1);
expect(entries[0]?.task.resolution).toBe('ambiguous');
});
it('preserves deleted peer tasks on relationship rows', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
});
const deletedPeer = makeTask({
id: '123e4567-e89b-12d3-a456-426614174002',
displayId: 'ijkl9012',
subject: 'Task B',
status: 'deleted',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/relationships.jsonl',
uuid: 'msg-3',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
agentName: 'lead',
isSidechain: false,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'tool-3',
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
{
schemaVersion: 1,
toolUseId: 'tool-3',
task: { ref: 'ijkl9012', refKind: 'display', canonicalId: deletedPeer.id },
targetRole: 'related',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'tool-3',
canonicalToolName: 'task_link',
input: { relationship: 'related' },
},
],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA, deletedPeer],
messages,
});
expect(entries).toHaveLength(1);
expect(entries[0]?.action?.peerTask?.resolution).toBe('deleted');
expect(entries[0]?.action?.details?.relationship).toBe('related');
expect(entries[0]?.action?.category).toBe('relationship');
expect(entries[0]?.action?.relationshipPerspective).toBe('symmetric');
});
it('resolves display locators case-insensitively and canonical-like unknown refs safely', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/case.jsonl',
uuid: 'msg-4',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
isSidechain: false,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'ABCD1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
{
schemaVersion: 1,
task: { ref: taskA.id, refKind: 'unknown' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
],
boardTaskToolActions: [],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA],
messages,
});
expect(entries).toHaveLength(2);
expect(entries[0]?.task.resolution).toBe('resolved');
expect(entries[1]?.task.resolution).toBe('resolved');
});
it('marks main-session actor without explicit name as unknown instead of forcing lead', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/unknown-actor.jsonl',
uuid: 'msg-5',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
isSidechain: false,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
],
boardTaskToolActions: [],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA],
messages,
});
expect(entries).toHaveLength(1);
expect(entries[0]?.actor.role).toBe('unknown');
});
it('never joins action payloads onto execution rows', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174000',
displayId: 'abcd1234',
subject: 'Task A',
status: 'in_progress',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/execution-malformed.jsonl',
uuid: 'msg-6',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
agentId: 'agent-a',
agentName: 'alice',
isSidechain: true,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'tool-1',
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'tool-1',
canonicalToolName: 'task_start',
},
],
},
];
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA],
messages,
});
expect(entries).toHaveLength(1);
expect(entries[0]?.linkKind).toBe('execution');
expect(entries[0]?.action).toBeUndefined();
});
it('derives relationship perspective from target role', () => {
const taskA = makeTask({
id: '123e4567-e89b-12d3-a456-426614174010',
displayId: 'taska010',
subject: 'Task A',
status: 'in_progress',
});
const taskB = makeTask({
id: '123e4567-e89b-12d3-a456-426614174011',
displayId: 'taskb011',
subject: 'Task B',
status: 'pending',
});
const messages: RawTaskActivityMessage[] = [
{
filePath: '/tmp/relationship-perspective.jsonl',
uuid: 'msg-7',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
agentName: 'lead',
isSidechain: false,
sourceOrder: 1,
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'tool-7',
task: { ref: 'taska010', refKind: 'display', canonicalId: taskA.id },
targetRole: 'subject',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
{
schemaVersion: 1,
toolUseId: 'tool-7',
task: { ref: 'taskb011', refKind: 'display', canonicalId: taskB.id },
targetRole: 'related',
linkKind: 'board_action',
actorContext: { relation: 'idle' },
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'tool-7',
canonicalToolName: 'task_link',
input: { relationship: 'blocked-by' },
},
],
},
];
const builder = new BoardTaskActivityEntryBuilder();
const entriesForTaskA = builder.buildForTask({
teamName: 'demo',
targetTask: taskA,
tasks: [taskA, taskB],
messages,
});
const entriesForTaskB = builder.buildForTask({
teamName: 'demo',
targetTask: taskB,
tasks: [taskA, taskB],
messages,
});
expect(entriesForTaskA).toHaveLength(1);
expect(entriesForTaskA[0]?.action?.relationshipPerspective).toBe('incoming');
expect(entriesForTaskA[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskB.id);
expect(entriesForTaskB).toHaveLength(1);
expect(entriesForTaskB[0]?.action?.relationshipPerspective).toBe('outgoing');
expect(entriesForTaskB[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskA.id);
});
});

View file

@ -0,0 +1,82 @@
import { describe, expect, it, vi } from 'vitest';
import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource';
describe('BoardTaskActivityRecordSource', () => {
it('uses active and deleted tasks together when building explicit task records', async () => {
const targetTask = {
id: 'task-a',
displayId: 'abcd1234',
subject: 'A',
status: 'pending',
};
const deletedTask = {
id: 'task-b',
displayId: 'deadbeef',
subject: 'B',
status: 'deleted',
};
const transcriptFiles = ['/tmp/a.jsonl'];
const rawMessages = [{ uuid: 'm1' }];
const builtRecords = [{ id: 'r1' }];
const locator = {
listTranscriptFiles: vi.fn(async () => transcriptFiles),
};
const taskReader = {
getTasks: vi.fn(async () => [targetTask]),
getDeletedTasks: vi.fn(async () => [deletedTask]),
};
const transcriptReader = {
readFiles: vi.fn(async () => rawMessages),
};
const recordBuilder = {
buildForTask: vi.fn(() => builtRecords),
};
const source = new BoardTaskActivityRecordSource(
locator as never,
taskReader as never,
transcriptReader as never,
recordBuilder as never,
);
const result = await source.getTaskRecords('demo', 'task-a');
expect(result).toBe(builtRecords);
expect(locator.listTranscriptFiles).toHaveBeenCalledWith('demo');
expect(transcriptReader.readFiles).toHaveBeenCalledWith(transcriptFiles);
expect(recordBuilder.buildForTask).toHaveBeenCalledWith({
teamName: 'demo',
targetTask,
tasks: [targetTask, deletedTask],
messages: rawMessages,
});
});
it('returns empty when the target task is unknown', async () => {
const locator = {
listTranscriptFiles: vi.fn(async () => ['/tmp/a.jsonl']),
};
const taskReader = {
getTasks: vi.fn(async () => []),
getDeletedTasks: vi.fn(async () => []),
};
const transcriptReader = {
readFiles: vi.fn(async () => [{ uuid: 'm1' }]),
};
const recordBuilder = {
buildForTask: vi.fn(() => [{ id: 'r1' }]),
};
const source = new BoardTaskActivityRecordSource(
locator as never,
taskReader as never,
transcriptReader as never,
recordBuilder as never,
);
await expect(source.getTaskRecords('demo', 'task-missing')).resolves.toEqual([]);
expect(recordBuilder.buildForTask).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,67 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
const tempPaths: string[] = [];
async function createTempTranscript(lines: unknown[]): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'board-task-activity-'));
const filePath = path.join(dir, 'transcript.jsonl');
tempPaths.push(dir);
await fs.writeFile(
filePath,
lines.map(line => JSON.stringify(line)).join('\n'),
'utf8',
);
return filePath;
}
afterEach(async () => {
await Promise.all(
tempPaths.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })),
);
});
describe('BoardTaskActivityTranscriptReader', () => {
it('skips transcript rows without a stable timestamp', async () => {
const filePath = await createTempTranscript([
{
uuid: 'missing-timestamp',
sessionId: 'session-1',
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
],
},
{
uuid: 'valid-row',
timestamp: '2026-04-12T10:00:00.000Z',
sessionId: 'session-1',
boardTaskLinks: [
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
],
},
]);
const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]);
expect(rows).toHaveLength(1);
expect(rows[0]?.uuid).toBe('valid-row');
expect(rows[0]?.timestamp).toBe('2026-04-12T10:00:00.000Z');
});
});

View file

@ -0,0 +1,40 @@
import { describe, expect, it, vi } from 'vitest';
import { BoardTaskExactLogChunkBuilder } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder';
import type { EnhancedChunk, ParsedMessage } from '../../../../src/main/types';
describe('BoardTaskExactLogChunkBuilder', () => {
it('delegates to ChunkBuilder with includeSidechain enabled', () => {
const buildChunks = vi.fn<() => EnhancedChunk[]>(() => []);
const messages = [{ uuid: 'm1' }] as unknown as ParsedMessage[];
const builder = new BoardTaskExactLogChunkBuilder({ buildChunks } as never);
const result = builder.buildBundleChunks(messages);
expect(result).toEqual([]);
expect(buildChunks).toHaveBeenCalledWith(messages, [], { includeSidechain: true });
});
it('does not crash on a minimal assistant-only bundle', () => {
const messages: ParsedMessage[] = [
{
uuid: 'assistant-1',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-12T18:00:00.000Z'),
role: 'assistant',
content: [{ type: 'text', text: 'done' } as never],
toolCalls: [],
toolResults: [],
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
];
const chunks = new BoardTaskExactLogChunkBuilder().buildBundleChunks(messages);
expect(chunks.length).toBeGreaterThan(0);
});
});

View file

@ -0,0 +1,305 @@
import { describe, expect, it } from 'vitest';
import { BoardTaskExactLogDetailSelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector';
import type { ParsedMessage } from '../../../../src/main/types';
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes';
function makeRecord(): BoardTaskActivityRecord {
return {
id: 'record-1',
timestamp: '2026-04-12T16: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: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
actorContext: { relation: 'same_task' },
action: {
canonicalToolName: 'task_add_comment',
toolUseId: 'tool-1',
category: 'comment',
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
};
}
function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate {
return {
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: records[0]!.actor,
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
records,
anchor: {
kind: 'tool',
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
},
actionLabel: 'Added a comment',
actionCategory: 'comment',
canonicalToolName: 'task_add_comment',
linkKinds: ['board_action'],
targetRoles: ['subject'],
canLoadDetail: true,
sourceGeneration: 'gen-1',
};
}
describe('BoardTaskExactLogDetailSelector', () => {
it('keeps the matched tool flow, preserves anchor output, and deduplicates assistant streaming rows anchor-aware', () => {
const records = [makeRecord()];
const candidate = makeCandidate(records);
const parsedMessagesByFile = new Map<string, ParsedMessage[]>([
[
'/tmp/task.jsonl',
[
{
uuid: 'assistant-0',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [
{ type: 'thinking', thinking: 'draft' } as never,
{ type: 'text', text: 'old tool draft' } as never,
{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never,
],
toolCalls: [],
toolResults: [],
requestId: 'req-1',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'assistant-1',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-12T16:00:01.000Z'),
role: 'assistant',
content: [
{ type: 'text', text: 'stream tail without anchor tool call' } as never,
{ type: 'tool_use', id: 'tool-2', name: 'task_get', input: { taskId: 'y' } } as never,
],
toolCalls: [],
toolResults: [],
requestId: 'req-1',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-1',
parentUuid: null,
type: 'user',
timestamp: new Date('2026-04-12T16:00:02.000Z'),
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never,
{ type: 'tool_result', tool_use_id: 'tool-2', content: 'ignore' } as never,
],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
sourceToolAssistantUUID: 'assistant-1',
toolUseResult: { output: 'kept' },
requestId: 'req-1',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'assistant-2',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-12T16:00:03.000Z'),
role: 'assistant',
content: [{ type: 'text', text: 'comment saved' } as never],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
requestId: 'req-2',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
],
],
]);
const detail = new BoardTaskExactLogDetailSelector().selectDetail({
candidate,
records,
parsedMessagesByFile,
});
expect(detail).not.toBeNull();
expect(detail?.filteredMessages).toHaveLength(3);
expect(detail?.filteredMessages[0]?.uuid).toBe('assistant-0');
expect(detail?.filteredMessages[1]?.uuid).toBe('user-1');
expect(detail?.filteredMessages[2]?.uuid).toBe('assistant-2');
expect(detail?.filteredMessages[0]?.toolCalls).toHaveLength(1);
expect(detail?.filteredMessages[1]?.toolResults).toHaveLength(1);
expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({ output: 'kept' });
expect(detail?.filteredMessages[1]?.sourceToolAssistantUUID).toBeUndefined();
expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1');
});
it('drops stale derived tool metadata when a message-linked row survives filtering', () => {
const record = {
...makeRecord(),
id: 'record-message-1',
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'user-2',
sourceOrder: 2,
},
action: undefined,
} satisfies BoardTaskActivityRecord;
const candidate: BoardTaskExactLogBundleCandidate = {
id: 'message:/tmp/task.jsonl:user-2',
timestamp: '2026-04-12T16:01:00.000Z',
actor: record.actor,
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'user-2',
sourceOrder: 2,
},
records: [record],
anchor: {
kind: 'message',
filePath: '/tmp/task.jsonl',
messageUuid: 'user-2',
},
actionLabel: 'Worked on task',
linkKinds: ['execution'],
targetRoles: ['subject'],
canLoadDetail: true,
sourceGeneration: 'gen-2',
};
const parsedMessagesByFile = new Map<string, ParsedMessage[]>([
[
'/tmp/task.jsonl',
[
{
uuid: 'user-2',
parentUuid: null,
type: 'user',
timestamp: new Date('2026-04-12T16:01:00.000Z'),
role: 'user',
content: [
{ type: 'text', text: 'status update' } as never,
{ type: 'tool_result', tool_use_id: 'other-tool', content: 'stale tool result' } as never,
],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'other-tool',
sourceToolAssistantUUID: 'assistant-other',
toolUseResult: { output: 'stale' },
requestId: 'req-2',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
],
],
]);
const detail = new BoardTaskExactLogDetailSelector().selectDetail({
candidate,
records: [record],
parsedMessagesByFile,
});
expect(detail).not.toBeNull();
expect(detail?.filteredMessages).toHaveLength(1);
expect(detail?.filteredMessages[0]?.content).toEqual([{ type: 'text', text: 'status update' }]);
expect(detail?.filteredMessages[0]?.toolResults).toEqual([]);
expect(detail?.filteredMessages[0]?.sourceToolUseID).toBeUndefined();
expect(detail?.filteredMessages[0]?.sourceToolAssistantUUID).toBeUndefined();
expect(detail?.filteredMessages[0]?.toolUseResult).toBeUndefined();
});
it('preserves toolUseResult for a matched tool_result even when sourceToolUseID is absent', () => {
const records = [makeRecord()];
const candidate = makeCandidate(records);
const parsedMessagesByFile = new Map<string, ParsedMessage[]>([
[
'/tmp/task.jsonl',
[
{
uuid: 'assistant-1',
parentUuid: null,
type: 'assistant',
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [
{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never,
],
toolCalls: [],
toolResults: [],
requestId: 'req-1',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-1',
parentUuid: null,
type: 'user',
timestamp: new Date('2026-04-12T16:00:01.000Z'),
role: 'user',
content: [
{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never,
],
toolCalls: [],
toolResults: [],
toolUseResult: {
toolUseId: 'tool-1',
content: 'ok',
},
requestId: 'req-1',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
],
],
]);
const detail = new BoardTaskExactLogDetailSelector().selectDetail({
candidate,
records,
parsedMessagesByFile,
});
expect(detail).not.toBeNull();
expect(detail?.filteredMessages).toHaveLength(2);
expect(detail?.filteredMessages[1]?.sourceToolUseID).toBe('tool-1');
expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({
toolUseId: 'tool-1',
content: 'ok',
});
});
});

View file

@ -0,0 +1,212 @@
import { describe, expect, it, vi } from 'vitest';
import { BoardTaskExactLogDetailService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService';
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
import type {
BoardTaskExactLogBundleCandidate,
BoardTaskExactLogDetailCandidate,
} from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes';
function makeRecord(): BoardTaskActivityRecord {
return {
id: 'record-1',
timestamp: '2026-04-12T16: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: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
actorContext: { relation: 'same_task' },
action: {
canonicalToolName: 'task_add_comment',
toolUseId: 'tool-1',
category: 'comment',
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
};
}
function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate {
return {
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: records[0]!.actor,
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
records,
anchor: {
kind: 'tool',
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-1',
},
actionLabel: 'Added a comment',
actionCategory: 'comment',
canonicalToolName: 'task_add_comment',
linkKinds: ['board_action'],
targetRoles: ['subject'],
canLoadDetail: true,
sourceGeneration: 'gen-1',
};
}
describe('BoardTaskExactLogDetailService', () => {
it('returns missing when the exact-log read flag is disabled', async () => {
vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false');
const recordSource = { getTaskRecords: vi.fn(async () => []) };
const service = new BoardTaskExactLogDetailService(
recordSource as never,
{ selectSummaries: vi.fn() } as never,
{ parseFiles: vi.fn() } as never,
{ selectDetail: vi.fn() } as never,
{ buildBundleChunks: vi.fn() } as never
);
await expect(
service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-1')
).resolves.toEqual({ status: 'missing' });
expect(recordSource.getTaskRecords).not.toHaveBeenCalled();
vi.unstubAllEnvs();
});
it('returns stale when the expected source generation no longer matches', async () => {
const records = [makeRecord()];
const recordSource = { getTaskRecords: vi.fn(async () => records) };
const summarySelector = {
selectSummaries: vi.fn(() => [makeCandidate(records)]),
};
const service = new BoardTaskExactLogDetailService(
recordSource as never,
summarySelector as never,
{ parseFiles: vi.fn() } as never,
{ selectDetail: vi.fn() } as never,
{ buildBundleChunks: vi.fn() } as never
);
const result = await service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-old');
expect(result).toEqual({ status: 'stale' });
});
it('returns ok when a matching detail bundle is reconstructed', async () => {
const records = [makeRecord()];
const candidate = makeCandidate(records);
const detailCandidate: BoardTaskExactLogDetailCandidate = {
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records,
filteredMessages: [],
};
const recordSource = { getTaskRecords: vi.fn(async () => records) };
const summarySelector = {
selectSummaries: vi.fn(() => [candidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(() => detailCandidate),
};
const chunkBuilder = {
buildBundleChunks: vi.fn(() => []),
};
const service = new BoardTaskExactLogDetailService(
recordSource as never,
summarySelector as never,
strictParser as never,
detailSelector as never,
chunkBuilder as never
);
const result = await service.getTaskExactLogDetail(
'demo',
'task-a',
candidate.id,
'gen-1'
);
expect(result).toEqual({
status: 'ok',
detail: {
id: candidate.id,
chunks: [],
},
});
});
it('returns missing for non-expandable summaries without parsing transcript content', async () => {
const records = [makeRecord()];
const nonExpandableCandidate: BoardTaskExactLogBundleCandidate = {
...makeCandidate(records),
canLoadDetail: false,
};
const recordSource = { getTaskRecords: vi.fn(async () => records) };
const summarySelector = {
selectSummaries: vi.fn(() => [nonExpandableCandidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map()),
};
const service = new BoardTaskExactLogDetailService(
recordSource as never,
summarySelector as never,
strictParser as never,
{ selectDetail: vi.fn() } as never,
{ buildBundleChunks: vi.fn() } as never
);
const result = await service.getTaskExactLogDetail('demo', 'task-a', nonExpandableCandidate.id, 'gen-1');
expect(result).toEqual({ status: 'missing' });
expect(strictParser.parseFiles).not.toHaveBeenCalled();
});
it('returns missing when strict detail reconstruction fails for malformed transcript data', async () => {
const records = [makeRecord()];
const candidate = makeCandidate(records);
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(() => null),
};
const service = new BoardTaskExactLogDetailService(
{ getTaskRecords: vi.fn(async () => records) } as never,
{ selectSummaries: vi.fn(() => [candidate]) } as never,
strictParser as never,
detailSelector as never,
{ buildBundleChunks: vi.fn() } as never
);
const result = await service.getTaskExactLogDetail('demo', 'task-a', candidate.id, 'gen-1');
expect(result).toEqual({ status: 'missing' });
expect(strictParser.parseFiles).toHaveBeenCalledWith(['/tmp/task.jsonl']);
expect(detailSelector.selectDetail).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,47 @@
import { afterEach, describe, expect, it } from 'vitest';
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { BoardTaskExactLogStrictParser } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser';
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0, tempDirs.length).map(async (dirPath) => {
await fs.rm(dirPath, { recursive: true, force: true });
}),
);
});
describe('BoardTaskExactLogStrictParser', () => {
it('drops malformed timestamp rows instead of assigning them synthetic time', async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-'));
tempDirs.push(tempDir);
const filePath = path.join(tempDir, 'session.jsonl');
await fs.writeFile(
filePath,
[
JSON.stringify({
uuid: 'bad-ts',
type: 'assistant',
timestamp: 'not-a-real-date',
message: { role: 'assistant', content: 'bad row' },
}),
JSON.stringify({
uuid: 'good-ts',
type: 'assistant',
timestamp: '2026-04-12T18:00:00.000Z',
message: { role: 'assistant', content: 'good row' },
}),
].join('\n'),
'utf8',
);
const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
expect(parsed.get(filePath)?.map((message) => message.uuid)).toEqual(['good-ts']);
});
});

View file

@ -0,0 +1,145 @@
import { describe, expect, it } from 'vitest';
import { BoardTaskExactLogSummarySelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector';
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
function makeRecord(
id: string,
overrides: Partial<BoardTaskActivityRecord> = {}
): BoardTaskActivityRecord {
return {
id,
timestamp: '2026-04-12T16: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: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
actorContext: { relation: 'same_task' },
action: {
canonicalToolName: 'task_add_comment',
toolUseId: 'tool-1',
category: 'comment',
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
sourceOrder: 1,
},
...overrides,
};
}
describe('BoardTaskExactLogSummarySelector', () => {
it('prefers tool anchors over message anchors within one message group', () => {
const selector = new BoardTaskExactLogSummarySelector();
const records = [
makeRecord('r1', { source: { filePath: '/tmp/task.jsonl', messageUuid: 'msg-1', sourceOrder: 1 } }),
makeRecord('r2', {
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-1',
sourceOrder: 2,
},
}),
];
const summaries = selector.selectSummaries({
records,
fileVersionsByPath: new Map([
['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }],
]),
});
expect(summaries).toHaveLength(1);
expect(summaries[0]?.id).toBe('tool:/tmp/task.jsonl:tool-1');
expect(summaries[0]?.source.toolUseId).toBe('tool-1');
expect(summaries[0]?.anchor.kind).toBe('tool');
expect(summaries[0]?.actionLabel).toBe('Added a comment');
expect(summaries[0]?.actionCategory).toBe('comment');
expect(summaries[0]?.canonicalToolName).toBe('task_add_comment');
expect(summaries[0]?.records).toHaveLength(2);
expect(summaries[0]?.canLoadDetail).toBe(true);
});
it('marks summaries as non-expandable when file version metadata is missing', () => {
const selector = new BoardTaskExactLogSummarySelector();
const summaries = selector.selectSummaries({
records: [makeRecord('r1')],
fileVersionsByPath: new Map(),
});
expect(summaries).toHaveLength(1);
expect(summaries[0]?.canLoadDetail).toBe(false);
});
it('builds distinct action labels for multiple tool-linked bundles from the same actor', () => {
const selector = new BoardTaskExactLogSummarySelector();
const records = [
makeRecord('r1', {
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-1',
toolUseId: 'tool-comment',
sourceOrder: 1,
},
action: {
canonicalToolName: 'task_add_comment',
toolUseId: 'tool-comment',
category: 'comment',
},
}),
makeRecord('r2', {
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-2',
toolUseId: 'tool-review',
sourceOrder: 2,
},
action: {
canonicalToolName: 'review_request',
toolUseId: 'tool-review',
category: 'review',
details: { reviewer: 'tom' },
},
}),
makeRecord('r3', {
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'msg-3',
toolUseId: 'tool-read',
sourceOrder: 3,
},
action: {
canonicalToolName: 'task_get',
toolUseId: 'tool-read',
category: 'read',
},
}),
];
const summaries = selector.selectSummaries({
records,
fileVersionsByPath: new Map([
['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }],
]),
});
expect(summaries).toHaveLength(3);
expect(summaries.map((summary) => summary.actionLabel)).toEqual([
'Added a comment',
'Requested review from tom',
'Viewed task',
]);
});
});

View file

@ -0,0 +1,82 @@
import * as fs from 'fs/promises';
import * as os from 'os';
import * as path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardTaskExactLogsService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogsService';
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
const tempDirs: string[] = [];
async function createTempTranscript(): Promise<string> {
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-summary-'));
tempDirs.push(dir);
const filePath = path.join(dir, 'transcript.jsonl');
await fs.writeFile(filePath, '{"uuid":"x","type":"user","timestamp":"2026-04-12T16:00:00.000Z","message":{"role":"user","content":"hi"}}\n', 'utf8');
return filePath;
}
function makeRecord(filePath: string, id: string, timestamp: string, sourceOrder: number): BoardTaskActivityRecord {
return {
id,
timestamp,
task: {
locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' },
resolution: 'resolved',
},
linkKind: 'board_action',
targetRole: 'subject',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
actorContext: { relation: 'same_task' },
source: {
filePath,
messageUuid: id,
sourceOrder,
},
};
}
afterEach(async () => {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true })));
vi.unstubAllEnvs();
});
describe('BoardTaskExactLogsService', () => {
it('returns empty when the exact-log read flag is disabled', async () => {
vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false');
const recordSource = {
getTaskRecords: vi.fn(async () => {
throw new Error('should not be called');
}),
};
const service = new BoardTaskExactLogsService(recordSource as never);
await expect(service.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({ items: [] });
expect(recordSource.getTaskRecords).not.toHaveBeenCalled();
});
it('returns summaries in deterministic source order for the renderer to present', async () => {
const filePath = await createTempTranscript();
const recordSource = {
getTaskRecords: vi.fn(async () => [
makeRecord(filePath, 'msg-older', '2026-04-12T16:00:00.000Z', 1),
makeRecord(filePath, 'msg-newer', '2026-04-12T16:05:00.000Z', 2),
]),
};
const service = new BoardTaskExactLogsService(recordSource as never);
const response = await service.getTaskExactLogSummaries('demo', 'task-a');
expect(response.items).toHaveLength(2);
expect(response.items[0]?.timestamp).toBe('2026-04-12T16:00:00.000Z');
expect(response.items[1]?.timestamp).toBe('2026-04-12T16:05:00.000Z');
});
});

View file

@ -0,0 +1,311 @@
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource';
import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
import type { TeamTask } from '../../../../src/shared/types';
const TEAM_NAME = 'beacon-desk-2';
const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7';
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
return {
id: TASK_ID,
displayId: 'c414cd52',
subject: 'Help alice: fast lint/link check',
status: 'completed',
workIntervals: [
{
startedAt: '2026-04-12T15:36:00.000Z',
completedAt: '2026-04-12T15:40:00.000Z',
},
],
...overrides,
};
}
function createAssistantEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
agentName?: string;
sessionId?: string;
requestId?: string;
}): Record<string, unknown> {
return {
type: 'assistant',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
requestId: args.requestId,
message: {
id: `${args.uuid}-msg`,
role: 'assistant',
model: 'claude-test',
type: 'message',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
content: args.content,
},
};
}
function createUserEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
boardTaskLinks?: unknown[];
boardTaskToolActions?: unknown[];
toolUseResult?: Record<string, unknown>;
sourceToolAssistantUUID?: string;
agentName?: string;
sessionId?: string;
}): Record<string, unknown> {
return {
type: 'user',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}),
...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}),
...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}),
...(args.sourceToolAssistantUUID
? { sourceToolAssistantUUID: args.sourceToolAssistantUUID }
: {}),
message: {
role: 'user',
content: args.content,
},
};
}
describe('BoardTaskLogDiagnosticsService', () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
it('explains when worker tools exist in transcript but only board MCP actions are explicit', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-diagnostics-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const task = createTask();
const lines = [
createAssistantEntry({
uuid: 'a-task-start',
timestamp: '2026-04-12T15:36:00.000Z',
requestId: 'req-start',
content: [
{
type: 'tool_use',
id: 'call-task-start',
name: 'mcp__agent-teams__task_start',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
},
},
],
}),
createUserEntry({
uuid: 'u-task-start',
timestamp: '2026-04-12T15:36:00.100Z',
sourceToolAssistantUUID: 'a-task-start',
content: [
{
type: 'tool_result',
tool_use_id: 'call-task-start',
content: 'ok',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'idle',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
canonicalToolName: 'task_start',
},
],
toolUseResult: {
toolUseId: 'call-task-start',
content: '{"id":"c414cd52"}',
},
}),
createAssistantEntry({
uuid: 'a-grep',
timestamp: '2026-04-12T15:36:14.522Z',
requestId: 'req-grep',
content: [
{
type: 'tool_use',
id: 'call-grep',
name: 'Grep',
input: {
pattern: 'ITERATION_PLAN',
path: 'docs-site',
},
},
],
}),
createUserEntry({
uuid: 'u-grep',
timestamp: '2026-04-12T15:36:14.749Z',
sourceToolAssistantUUID: 'a-grep',
content: [
{
type: 'tool_result',
tool_use_id: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
],
toolUseResult: {
toolUseId: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
}),
createAssistantEntry({
uuid: 'a-comment',
timestamp: '2026-04-12T15:36:30.000Z',
requestId: 'req-comment',
content: [
{
type: 'tool_use',
id: 'call-comment',
name: 'mcp__agent-teams__task_add_comment',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
text: 'Audit complete',
},
},
],
}),
createUserEntry({
uuid: 'u-comment',
timestamp: '2026-04-12T15:36:30.100Z',
sourceToolAssistantUUID: 'a-comment',
content: [
{
type: 'tool_result',
tool_use_id: 'call-comment',
content: '{"comment":{"text":"Audit complete"}}',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'board_action',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
canonicalToolName: 'task_add_comment',
resultRefs: {
commentId: 'comment-1',
},
},
],
toolUseResult: {
toolUseId: 'call-comment',
content: '{"comment":{"text":"Audit complete"}}',
},
}),
];
await writeFile(
transcriptPath,
lines.map((line) => JSON.stringify(line)).join('\n'),
'utf8',
);
const taskReader = {
getTasks: async () => [task],
getDeletedTasks: async () => [] as TeamTask[],
};
const transcriptSourceLocator = {
listTranscriptFiles: async () => [transcriptPath],
};
const recordSource = new BoardTaskActivityRecordSource(
transcriptSourceLocator as never,
taskReader as never,
new BoardTaskActivityTranscriptReader(),
new BoardTaskActivityRecordBuilder(),
);
const streamService = new BoardTaskLogStreamService(recordSource);
const diagnosticsService = new BoardTaskLogDiagnosticsService(
taskReader as never,
transcriptSourceLocator as never,
recordSource,
undefined,
streamService,
);
const report = await diagnosticsService.diagnose(TEAM_NAME, '#c414cd52');
expect(report.explicitRecords.execution).toBe(0);
expect(report.intervalToolResults.worker.total).toBe(1);
expect(report.intervalToolResults.worker.explicitLinked).toBe(0);
expect(report.intervalToolResults.worker.missingExplicit).toBe(1);
expect(report.intervalToolResults.worker.examples).toContainEqual(
expect.objectContaining({
toolName: 'Grep',
toolUseId: 'call-grep',
}),
);
expect(report.stream.visibleToolNames).toEqual([
'mcp__agent-teams__task_start',
'mcp__agent-teams__task_add_comment',
]);
expect(report.diagnosis.join(' ')).toContain('Only board MCP actions are explicit');
});
});

View file

@ -0,0 +1,72 @@
import * as os from 'os';
import * as path from 'path';
import { afterAll, beforeAll, describe, expect, it } 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';
const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim();
const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim();
const LIVE_CLAUDE_BASE =
process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude');
const EXPECT_MISSING_WORKER_LINKS =
process.env.LIVE_TASK_LOG_EXPECT_MISSING_WORKER_LINKS === '1';
const EXPECT_NO_EMPTY_PAYLOADS =
process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1';
const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const describeLive =
LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip;
describeLive('BoardTaskLogStream live smoke', () => {
beforeAll(() => {
setClaudeBasePathOverride(LIVE_CLAUDE_BASE);
});
afterAll(() => {
setClaudeBasePathOverride(null);
});
it('diagnoses the current live task-log state', async () => {
const service = new BoardTaskLogDiagnosticsService();
const streamService = new BoardTaskLogStreamService();
let report;
try {
report = await service.diagnose(LIVE_TEAM!, LIVE_TASK!);
} catch (error) {
const fallbackTaskRef =
LIVE_TASK!.length > 8 && LIVE_TASK!.includes('-') ? LIVE_TASK!.slice(0, 8) : null;
if (!fallbackTaskRef) {
throw error;
}
report = await service.diagnose(LIVE_TEAM!, fallbackTaskRef);
}
expect(report.task.taskId).toBeTruthy();
expect(report.transcript.fileCount).toBeGreaterThan(0);
expect(report.diagnosis.length).toBeGreaterThan(0);
expect(report.stream.segmentCount).toBeGreaterThan(0);
const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId);
expect(stream.segments.length).toBeGreaterThan(0);
if (EXPECT_MISSING_WORKER_LINKS) {
expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0);
}
if (EXPECT_NO_EMPTY_PAYLOADS) {
expect(report.stream.emptyPayloadExamples).toHaveLength(0);
}
if (EXPECT_VISIBLE_TOOLS.length > 0) {
for (const toolName of EXPECT_VISIBLE_TOOLS) {
expect(report.stream.visibleToolNames).toContain(toolName);
}
}
});
});

View file

@ -0,0 +1,380 @@
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import { afterEach, describe, expect, it } from 'vitest';
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
import type { ParsedMessage } from '../../../../src/main/types';
import type { TeamTask } from '../../../../src/shared/types';
const TEAM_NAME = 'beacon-desk-2';
const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7';
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
return {
id: TASK_ID,
displayId: 'c414cd52',
subject: 'Help alice: fast lint/link check',
status: 'completed',
...overrides,
};
}
function createAssistantEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
agentName?: string;
sessionId?: string;
requestId?: string;
}): Record<string, unknown> {
return {
type: 'assistant',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
requestId: args.requestId,
message: {
id: `${args.uuid}-msg`,
role: 'assistant',
model: 'claude-test',
type: 'message',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
content: args.content,
},
};
}
function createUserEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
boardTaskLinks?: unknown[];
boardTaskToolActions?: unknown[];
toolUseResult?: unknown;
sourceToolAssistantUUID?: string;
agentName?: string;
sessionId?: string;
}): Record<string, unknown> {
return {
type: 'user',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}),
...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}),
...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}),
...(args.sourceToolAssistantUUID
? { sourceToolAssistantUUID: args.sourceToolAssistantUUID }
: {}),
message: {
role: 'user',
content: args.content,
},
};
}
async function buildRecordsFromTranscript(filePath: string, task: TeamTask) {
const transcriptReader = new BoardTaskActivityTranscriptReader();
const recordBuilder = new BoardTaskActivityRecordBuilder();
const messages = await transcriptReader.readFiles([filePath]);
return recordBuilder.buildForTask({
teamName: TEAM_NAME,
targetTask: task,
tasks: [task],
messages,
});
}
function flattenRawMessages(response: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>): ParsedMessage[] {
return response.segments.flatMap((segment) =>
segment.chunks.flatMap((chunk) => chunk.rawMessages),
);
}
describe('BoardTaskLogStreamService integration', () => {
const tempDirs: string[] = [];
afterEach(async () => {
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
it('includes worker tool logs when transcript rows carry execution links with toolUseId', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-integration-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const task = createTask();
const lines = [
createUserEntry({
uuid: 'u-start',
timestamp: '2026-04-12T15:36:07.747Z',
content: [
{
type: 'tool_result',
tool_use_id: 'call-task-start',
content: 'ok',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'idle',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
canonicalToolName: 'task_start',
},
],
toolUseResult: {
toolUseId: 'call-task-start',
content: '{"id":"c414cd52"}',
},
}),
createAssistantEntry({
uuid: 'a-grep',
timestamp: '2026-04-12T15:36:14.522Z',
requestId: 'req-grep',
content: [
{
type: 'tool_use',
id: 'call-grep',
name: 'Grep',
input: {
pattern: 'ITERATION_PLAN',
path: 'docs-site',
},
},
],
}),
createUserEntry({
uuid: 'u-grep',
timestamp: '2026-04-12T15:36:14.749Z',
sourceToolAssistantUUID: 'a-grep',
content: [
{
type: 'tool_result',
tool_use_id: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-grep',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'execution',
actorContext: {
relation: 'same_task',
},
},
],
toolUseResult: {
toolUseId: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
}),
createAssistantEntry({
uuid: 'a-edit',
timestamp: '2026-04-12T15:36:40.000Z',
requestId: 'req-edit',
content: [
{
type: 'tool_use',
id: 'call-edit',
name: 'Edit',
input: {
file_path: 'docs-site/guide.md',
},
},
],
}),
createUserEntry({
uuid: 'u-edit',
timestamp: '2026-04-12T15:36:40.200Z',
sourceToolAssistantUUID: 'a-edit',
content: [
{
type: 'tool_result',
tool_use_id: 'call-edit',
content: 'File updated',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-edit',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'execution',
actorContext: {
relation: 'same_task',
},
},
],
toolUseResult: {
toolUseId: 'call-edit',
content: 'File updated',
},
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
const recordSource = {
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
};
const service = new BoardTaskLogStreamService(recordSource as never);
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
const rawMessages = flattenRawMessages(response);
const toolNames = rawMessages.flatMap((message) =>
message.toolCalls.map((toolCall) => toolCall.name),
);
expect(response.participants.map((participant) => participant.label)).toEqual(['tom']);
expect(response.defaultFilter).toBe('member:tom');
expect(response.segments).toHaveLength(1);
expect(toolNames).toContain('Grep');
expect(toolNames).toContain('Edit');
});
it('does not leak empty array board-tool payloads into the task log stream', async () => {
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-tool-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const task = createTask();
const lines = [
createAssistantEntry({
uuid: 'a-comment',
timestamp: '2026-04-12T18:35:02.000Z',
requestId: 'req-comment',
content: [
{
type: 'tool_use',
id: 'call-comment',
name: 'mcp__agent-teams__task_add_comment',
input: {
taskId: TASK_ID,
text: 'Done',
},
},
],
}),
createUserEntry({
uuid: 'u-comment',
timestamp: '2026-04-12T18:35:02.064Z',
sourceToolAssistantUUID: 'a-comment',
content: [
{
type: 'tool_result',
tool_use_id: 'call-comment',
content: [
{
type: 'text',
text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}',
},
],
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'board_action',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
canonicalToolName: 'task_add_comment',
resultRefs: {
commentId: 'comment-1',
},
},
],
toolUseResult: [
{
type: 'text',
text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}',
},
],
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
const recordSource = {
getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task),
};
const service = new BoardTaskLogStreamService(recordSource as never);
const response = await service.getTaskLogStream(TEAM_NAME, task.id);
const rawMessages = flattenRawMessages(response);
const commentResult = rawMessages.find((message) => message.uuid === 'u-comment');
expect(response.segments).toHaveLength(1);
expect(commentResult).toBeUndefined();
});
});

View file

@ -0,0 +1,639 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
import type { ParsedMessage } from '../../../../src/main/types';
import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord';
import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes';
function makeRecord(
id: string,
timestamp: string,
actor: BoardTaskActivityRecord['actor'],
toolUseId?: string,
): BoardTaskActivityRecord {
return {
id,
timestamp,
task: {
locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' },
resolution: 'resolved',
},
linkKind: 'board_action',
targetRole: 'subject',
actor,
actorContext: { relation: 'same_task' },
source: {
filePath: '/tmp/task.jsonl',
messageUuid: `${id}-msg`,
...(toolUseId ? { toolUseId } : {}),
sourceOrder: 1,
},
};
}
function makeCandidate(
id: string,
timestamp: string,
actor: BoardTaskActivityRecord['actor'],
toolUseId?: string,
): BoardTaskExactLogBundleCandidate {
const record = makeRecord(id, timestamp, actor, toolUseId);
return {
id,
timestamp,
actor,
source: {
filePath: '/tmp/task.jsonl',
messageUuid: `${id}-msg`,
...(toolUseId ? { toolUseId } : {}),
sourceOrder: 1,
},
records: [record],
anchor: toolUseId
? {
kind: 'tool',
filePath: '/tmp/task.jsonl',
messageUuid: `${id}-msg`,
toolUseId,
}
: {
kind: 'message',
filePath: '/tmp/task.jsonl',
messageUuid: `${id}-msg`,
},
actionLabel: 'Worked on task',
linkKinds: ['board_action'],
targetRoles: ['subject'],
canLoadDetail: true,
sourceGeneration: 'gen-1',
};
}
function makeMessage(uuid: string, timestamp: string, text: string): ParsedMessage {
return {
uuid,
parentUuid: null,
type: 'assistant',
timestamp: new Date(timestamp),
role: 'assistant',
content: [{ type: 'text', text } as never],
toolCalls: [],
toolResults: [],
isSidechain: true,
isMeta: false,
isCompactSummary: false,
};
}
describe('BoardTaskLogStreamService', () => {
afterEach(() => {
vi.unstubAllEnvs();
});
it('returns empty when the stream read flag is disabled', async () => {
vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false');
const recordSource = {
getTaskRecords: vi.fn(async () => {
throw new Error('should not be called');
}),
};
const service = new BoardTaskLogStreamService(recordSource as never);
await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({
participants: [],
defaultFilter: 'all',
segments: [],
});
expect(recordSource.getTaskRecords).not.toHaveBeenCalled();
});
it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => {
const tom = {
memberName: 'tom',
role: 'member' as const,
sessionId: 'session-tom',
agentId: 'agent-tom',
isSidechain: true,
};
const alice = {
memberName: 'alice',
role: 'member' as const,
sessionId: 'session-alice',
agentId: 'agent-alice',
isSidechain: true,
};
const lead = {
role: 'lead' as const,
sessionId: 'session-lead',
isSidechain: false,
};
const candidates = [
makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'),
makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'),
makeCandidate('c4', '2026-04-12T16:03:00.000Z', lead),
makeCandidate('c5', '2026-04-12T16:04:00.000Z', tom, 'tool-4'),
];
const recordSource = {
getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)),
};
const summarySelector = {
selectSummaries: vi.fn(() => candidates),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records: candidate.records,
filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)],
})),
};
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,
);
const response = await service.getTaskLogStream('demo', 'task-a');
expect(response.defaultFilter).toBe('all');
expect(response.participants.map((participant) => participant.key)).toEqual([
'member:tom',
'member:alice',
]);
expect(response.segments.map((segment) => segment.participantKey)).toEqual([
'member:tom',
'member:alice',
'member:tom',
]);
expect(buildBundleChunks).toHaveBeenCalledTimes(3);
expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2);
});
it('merges duplicate message uuids inside one participant segment before chunk building', async () => {
const tom = {
memberName: 'tom',
role: 'member' as const,
sessionId: 'session-tom',
agentId: 'agent-tom',
isSidechain: true,
};
const candidates = [
makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
makeCandidate('c2', '2026-04-12T16:00:10.000Z', tom, 'tool-2'),
];
const sharedMessage = {
uuid: 'assistant-shared',
parentUuid: null,
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
toolCalls: [],
toolResults: [],
isSidechain: true,
isMeta: false,
isCompactSummary: false,
};
const recordSource = {
getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)),
};
const summarySelector = {
selectSummaries: vi.fn(() => candidates),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi
.fn()
.mockImplementationOnce(() => ({
id: 'c1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: tom,
source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 1 },
records: candidates[0]!.records,
filteredMessages: [
{
...sharedMessage,
content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never],
},
],
}))
.mockImplementationOnce(() => ({
id: 'c2',
timestamp: '2026-04-12T16:00:10.000Z',
actor: tom,
source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 2 },
records: candidates[1]!.records,
filteredMessages: [
{
...sharedMessage,
content: [{ type: 'text', text: 'task looked up' } as never],
},
],
})),
};
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,
);
await service.getTaskLogStream('demo', 'task-a');
expect(buildBundleChunks).toHaveBeenCalledTimes(1);
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
expect(mergedMessages).toHaveLength(1);
expect(mergedMessages[0]?.toolCalls).toHaveLength(1);
expect(Array.isArray(mergedMessages[0]?.content)).toBe(true);
expect(mergedMessages[0]?.content).toHaveLength(2);
});
it('drops tool-anchored assistant output-only messages to avoid noisy raw result blocks', async () => {
const tom = {
memberName: 'tom',
role: 'member' as const,
sessionId: 'session-tom',
agentId: 'agent-tom',
isSidechain: true,
};
const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1');
const recordSource = {
getTaskRecords: vi.fn(async () => candidate.records),
};
const summarySelector = {
selectSummaries: vi.fn(() => [candidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(() => ({
id: 'c1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: tom,
source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 },
records: candidate.records,
filteredMessages: [
{
uuid: 'assistant-tool',
parentUuid: null,
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never],
toolCalls: [],
toolResults: [],
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'assistant-output',
parentUuid: 'assistant-tool',
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:01.000Z'),
role: 'assistant',
content: [{ type: 'text', text: '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\"\\n}\"}]' } as never],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
sourceToolAssistantUUID: 'assistant-tool',
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-result',
parentUuid: 'assistant-tool',
type: 'user' as const,
timestamp: new Date('2026-04-12T16:00:02.000Z'),
role: 'user',
content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
sourceToolAssistantUUID: 'assistant-tool',
toolUseResult: { toolUseId: 'tool-1', content: 'ok' },
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
],
})),
};
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,
);
await service.getTaskLogStream('demo', 'task-a');
expect(buildBundleChunks).toHaveBeenCalledTimes(1);
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
expect(mergedMessages.map((message) => message.uuid)).toEqual(['assistant-tool', 'user-result']);
});
it('defaults to the single named participant and excludes unnamed lead noise when named task logs exist', async () => {
const tom = {
memberName: 'tom',
role: 'lead' as const,
sessionId: 'session-tom',
isSidechain: false,
};
const unknownLead = {
role: 'unknown' as const,
sessionId: 'session-lead',
isSidechain: false,
};
const candidates = [
makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
makeCandidate('c2', '2026-04-12T16:01:00.000Z', unknownLead, 'tool-2'),
];
const recordSource = {
getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)),
};
const summarySelector = {
selectSummaries: vi.fn(() => candidates),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records: candidate.records,
filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)],
})),
};
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,
);
const response = await service.getTaskLogStream('demo', 'task-a');
expect(response.participants.map((participant) => participant.key)).toEqual(['member:tom']);
expect(response.defaultFilter).toBe('member:tom');
expect(response.segments.map((segment) => segment.participantKey)).toEqual(['member:tom']);
});
it('sanitizes json-like tool_result payload text while preserving the tool result message', async () => {
const tom = {
memberName: 'tom',
role: 'member' as const,
sessionId: 'session-tom',
agentId: 'agent-tom',
isSidechain: true,
};
const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1');
const recordSource = {
getTaskRecords: vi.fn(async () => candidate.records),
};
const summarySelector = {
selectSummaries: vi.fn(() => [candidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(() => ({
id: 'c1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: tom,
source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 },
records: candidate.records,
filteredMessages: [
{
uuid: 'assistant-tool',
parentUuid: null,
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never],
toolCalls: [],
toolResults: [],
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-result',
parentUuid: 'assistant-tool',
type: 'user' as const,
timestamp: new Date('2026-04-12T16:00:02.000Z'),
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: [{ type: 'text', text: '{\n \"id\": \"task-a\"\n}' } as never],
} as never,
],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
sourceToolAssistantUUID: 'assistant-tool',
toolUseResult: { toolUseId: 'tool-1', content: '{\n \"id\": \"task-a\"\n}' },
isSidechain: true,
isMeta: false,
isCompactSummary: false,
},
],
})),
};
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,
);
await service.getTaskLogStream('demo', 'task-a');
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result');
expect(toolResultMessage).toBeDefined();
const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : [];
expect(content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: '',
});
expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: '' });
});
it('drops read-only slices when the same participant has more meaningful task logs', async () => {
const tom = {
memberName: 'tom',
role: 'lead' as const,
sessionId: 'session-tom',
isSidechain: false,
};
const readCandidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), actionCategory: 'read' as const, canonicalToolName: 'task_get' };
const commentCandidate = { ...makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment' };
const recordSource = {
getTaskRecords: vi.fn(async () => [...readCandidate.records, ...commentCandidate.records]),
};
const summarySelector = {
selectSummaries: vi.fn(() => [readCandidate, commentCandidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({
id: candidate.id,
timestamp: candidate.timestamp,
actor: candidate.actor,
source: candidate.source,
records: candidate.records,
filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)],
})),
};
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,
);
const response = await service.getTaskLogStream('demo', 'task-a');
expect(response.segments).toHaveLength(1);
expect(buildBundleChunks).toHaveBeenCalledTimes(1);
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']);
});
it('extracts task_add_comment text from json-like tool result payload', async () => {
const tom = {
memberName: 'tom',
role: 'lead' as const,
sessionId: 'session-tom',
isSidechain: false,
};
const candidate = {
...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'),
actionCategory: 'comment' as const,
canonicalToolName: 'task_add_comment',
};
const recordSource = {
getTaskRecords: vi.fn(async () => candidate.records),
};
const summarySelector = {
selectSummaries: vi.fn(() => [candidate]),
};
const strictParser = {
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
};
const detailSelector = {
selectDetail: vi.fn(() => ({
id: 'c1',
timestamp: '2026-04-12T16:00:00.000Z',
actor: tom,
source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 },
records: candidate.records,
filteredMessages: [
{
uuid: 'assistant-tool',
parentUuid: null,
type: 'assistant' as const,
timestamp: new Date('2026-04-12T16:00:00.000Z'),
role: 'assistant',
content: [{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: {} } as never],
toolCalls: [],
toolResults: [],
isSidechain: false,
isMeta: false,
isCompactSummary: false,
},
{
uuid: 'user-result',
parentUuid: 'assistant-tool',
type: 'user' as const,
timestamp: new Date('2026-04-12T16:00:02.000Z'),
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'tool-1',
content: [{ type: 'text', text: '{\"comment\":{\"text\":\"useful comment\"}}' } as never],
} as never,
],
toolCalls: [],
toolResults: [],
sourceToolUseID: 'tool-1',
sourceToolAssistantUUID: 'assistant-tool',
toolUseResult: { toolUseId: 'tool-1', content: '{"comment":{"text":"useful comment"}}' },
isSidechain: false,
isMeta: false,
isCompactSummary: false,
},
],
})),
};
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,
);
await service.getTaskLogStream('demo', 'task-a');
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
const toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result');
const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : [];
expect(content[0]).toMatchObject({
type: 'tool_result',
tool_use_id: 'tool-1',
content: 'useful comment',
});
expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' });
});
});

View file

@ -0,0 +1,196 @@
import { describe, expect, it } from 'vitest';
import fixture from '../../../fixtures/team/board-task-activity-message-v1.json';
import {
parseBoardTaskLinks,
parseBoardTaskToolActions,
} from '../../../../src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract';
describe('BoardTaskTranscriptContract', () => {
it('salvages valid board-task links from mixed payloads', () => {
const parsed = parseBoardTaskLinks([
null,
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' },
targetRole: 'subject',
linkKind: 'lifecycle',
actorContext: { relation: 'idle' },
},
{
schemaVersion: 1,
task: { ref: '', refKind: 'display' },
targetRole: 'subject',
linkKind: 'lifecycle',
actorContext: { relation: 'idle' },
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' },
targetRole: 'subject',
linkKind: 'lifecycle',
actorContext: { relation: 'idle' },
},
]);
});
it('salvages valid task tool actions from mixed payloads', () => {
const parsed = parseBoardTaskToolActions([
{
schemaVersion: 1,
toolUseId: 'tool-1',
canonicalToolName: 'task_add_comment',
resultRefs: { commentId: 'comment-1' },
},
{
schemaVersion: 1,
canonicalToolName: 'task_add_comment',
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
toolUseId: 'tool-1',
canonicalToolName: 'task_add_comment',
resultRefs: { commentId: 'comment-1' },
},
]);
});
it('parses the documented fixture example', () => {
expect(parseBoardTaskLinks(fixture.boardTaskLinks)).toEqual([
{
schemaVersion: 1,
toolUseId: 'tool-1',
task: {
ref: 'abcd1234',
refKind: 'display',
canonicalId: '123e4567-e89b-12d3-a456-426614174000',
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: { relation: 'idle' },
},
]);
expect(parseBoardTaskToolActions(fixture.boardTaskToolActions)).toEqual([
{
schemaVersion: 1,
toolUseId: 'tool-1',
canonicalToolName: 'task_add_comment',
resultRefs: { commentId: 'comment-1' },
},
]);
});
it('preserves semantic null owner and clarification values', () => {
const parsed = parseBoardTaskToolActions([
{
schemaVersion: 1,
toolUseId: 'tool-2',
canonicalToolName: 'task_set_owner',
input: { owner: null },
},
{
schemaVersion: 1,
toolUseId: 'tool-3',
canonicalToolName: 'task_set_clarification',
input: { clarification: 'clear' },
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
toolUseId: 'tool-2',
canonicalToolName: 'task_set_owner',
input: { owner: null },
},
{
schemaVersion: 1,
toolUseId: 'tool-3',
canonicalToolName: 'task_set_clarification',
input: { clarification: null },
},
]);
});
it('accepts legacy version fields while preferring schemaVersion going forward', () => {
const parsed = parseBoardTaskLinks([
{
version: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
]);
});
it('sanitizes impossible actor scope details unless relation is other_active_task', () => {
const parsed = parseBoardTaskLinks([
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: {
relation: 'same_task',
activeTask: { ref: 'efgh5678', refKind: 'display' },
activePhase: 'work',
activeExecutionSeq: 2,
},
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
]);
});
it('preserves execution toolUseId while still dropping execution taskArgumentSlot', () => {
const parsed = parseBoardTaskLinks([
{
schemaVersion: 1,
toolUseId: 'tool-1',
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
taskArgumentSlot: 'taskId',
actorContext: { relation: 'same_task' },
},
]);
expect(parsed).toEqual([
{
schemaVersion: 1,
toolUseId: 'tool-1',
task: { ref: 'abcd1234', refKind: 'display' },
targetRole: 'subject',
linkKind: 'execution',
actorContext: { relation: 'same_task' },
},
]);
});
});

View file

@ -64,6 +64,53 @@ describe('TaskBoundaryParser', () => {
expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true);
});
it('detects fully-qualified agent-teams MCP task boundaries', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl');
await fs.writeFile(
jsonlPath,
[
JSON.stringify({
timestamp: '2026-03-01T10:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-1',
name: 'mcp__agent-teams__task_start',
input: { taskId: 'task-123', teamName: 'demo' },
},
],
},
}),
JSON.stringify({
timestamp: '2026-03-01T10:10:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
id: 'tool-2',
name: 'mcp__agent_teams__task_complete',
input: { taskId: 'task-123', teamName: 'demo' },
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath);
expect(result.detectedMechanism).toBe('mcp');
expect(result.boundaries).toHaveLength(2);
expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']);
});
it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');

View file

@ -867,6 +867,34 @@ describe('TeamMemberLogsFinder', () => {
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
});
it('detects fully-qualified agent-teams task markers in JSONL', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-markers-'));
const qualifiedPath = path.join(tmpDir, 'qualified.jsonl');
await fs.writeFile(
qualifiedPath,
JSON.stringify({
timestamp: '2026-01-01T00:00:00.000Z',
type: 'assistant',
message: {
role: 'assistant',
content: [
{
type: 'tool_use',
name: 'mcp__agent-teams__task_start',
input: { teamName: 'demo', taskId: 'task-42' },
},
],
},
}) + '\n',
'utf8'
);
const finder = new TeamMemberLogsFinder();
await expect(finder.hasTaskUpdateMarker(qualifiedPath, 'task-42')).resolves.toBe(true);
});
it('findLogFileRefsForTask returns correct refs for a task', async () => {
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-'));
setClaudeBasePathOverride(tmpDir);

View file

@ -0,0 +1,37 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
import { HttpAPIClient } from '../../../src/renderer/api/httpClient';
class MockEventSource {
onopen: (() => void) | null = null;
onerror: (() => void) | null = null;
addEventListener(): void {}
close(): void {}
}
describe('HttpAPIClient exact task logs browser fallback', () => {
afterEach(() => {
vi.unstubAllGlobals();
vi.restoreAllMocks();
});
it('returns safe fallback shapes for exact task logs in browser mode', async () => {
vi.stubGlobal('EventSource', MockEventSource);
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new HttpAPIClient('http://localhost:9999');
await expect(client.teams.getTaskLogStream('demo', 'task-a')).resolves.toEqual({
participants: [],
defaultFilter: 'all',
segments: [],
});
await expect(client.teams.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({
items: [],
});
await expect(
client.teams.getTaskExactLogDetail('demo', 'task-a', 'bundle-1', 'gen-1')
).resolves.toEqual({ status: 'missing' });
expect(warnSpy).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,288 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type {
BoardTaskExactLogDetailResult,
BoardTaskExactLogSummariesResponse,
} from '../../../../../src/shared/types';
const apiState = {
getTaskExactLogSummaries: vi.fn<
(teamName: string, taskId: string) => Promise<BoardTaskExactLogSummariesResponse>
>(),
getTaskExactLogDetail: vi.fn<
(
teamName: string,
taskId: string,
exactLogId: string,
expectedSourceGeneration: string
) => Promise<BoardTaskExactLogDetailResult>
>(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
getTaskExactLogSummaries: (...args: Parameters<typeof apiState.getTaskExactLogSummaries>) =>
apiState.getTaskExactLogSummaries(...args),
getTaskExactLogDetail: (...args: Parameters<typeof apiState.getTaskExactLogDetail>) =>
apiState.getTaskExactLogDetail(...args),
},
},
}));
vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({
MemberExecutionLog: ({ memberName }: { memberName?: string }) =>
React.createElement('div', { 'data-testid': 'member-execution-log' }, memberName ?? 'no-name'),
}));
import { ExactTaskLogsSection } from '@renderer/components/team/taskLogs/ExactTaskLogsSection';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
describe('ExactTaskLogsSection', () => {
afterEach(() => {
document.body.innerHTML = '';
apiState.getTaskExactLogSummaries.mockReset();
apiState.getTaskExactLogDetail.mockReset();
vi.unstubAllGlobals();
});
it('renders empty state when exact summaries are absent', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskExactLogSummaries.mockResolvedValueOnce({ items: [] });
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.textContent).toContain('Exact Task Logs');
expect(host.textContent).toContain('No exact task logs yet');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('renders loading state while summaries are still pending', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
let resolveSummaries: ((value: BoardTaskExactLogSummariesResponse) => void) | null = null;
apiState.getTaskExactLogSummaries.mockImplementationOnce(
() =>
new Promise<BoardTaskExactLogSummariesResponse>((resolve) => {
resolveSummaries = resolve;
})
);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.textContent).toContain('Loading exact task logs');
await act(async () => {
resolveSummaries?.({ items: [] });
await flushMicrotasks();
});
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('renders error state when summaries fail to load', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskExactLogSummaries.mockRejectedValueOnce(new Error('boom'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
await flushMicrotasks();
});
expect(host.textContent).toContain('boom');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('reloads summaries on stale detail and then renders exact detail', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskExactLogSummaries
.mockResolvedValueOnce({
items: [
{
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T18:00:00.000Z',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
anchorKind: 'tool',
actionLabel: 'Added a comment',
actionCategory: 'comment',
canonicalToolName: 'task_add_comment',
linkKinds: ['board_action'],
canLoadDetail: true,
sourceGeneration: 'gen-1',
},
],
})
.mockResolvedValueOnce({
items: [
{
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T18:00:00.000Z',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-1',
agentId: 'agent-1',
isSidechain: true,
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
anchorKind: 'tool',
actionLabel: 'Added a comment',
actionCategory: 'comment',
canonicalToolName: 'task_add_comment',
linkKinds: ['board_action'],
canLoadDetail: true,
sourceGeneration: 'gen-2',
},
],
});
apiState.getTaskExactLogDetail
.mockResolvedValueOnce({ status: 'stale' })
.mockResolvedValueOnce({
status: 'ok',
detail: {
id: 'tool:/tmp/task.jsonl:tool-1',
chunks: [],
},
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
const button = host.querySelector('button');
expect(button).not.toBeNull();
await act(async () => {
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flushMicrotasks();
await flushMicrotasks();
});
await vi.waitFor(() => {
expect(apiState.getTaskExactLogSummaries).toHaveBeenCalledTimes(2);
expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith(
1,
'demo',
'task-a',
'tool:/tmp/task.jsonl:tool-1',
'gen-1'
);
expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith(
2,
'demo',
'task-a',
'tool:/tmp/task.jsonl:tool-1',
'gen-2'
);
expect(host.querySelector('[data-testid=\"member-execution-log\"]')?.textContent).toBe('alice');
});
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('renders descriptive action labels and lead-session fallback actor text', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskExactLogSummaries.mockResolvedValueOnce({
items: [
{
id: 'tool:/tmp/task.jsonl:tool-1',
timestamp: '2026-04-12T18:00:00.000Z',
actor: {
role: 'lead',
sessionId: 'lead-session-1',
isSidechain: false,
},
source: {
filePath: '/tmp/task.jsonl',
messageUuid: 'assistant-1',
toolUseId: 'tool-1',
sourceOrder: 1,
},
anchorKind: 'tool',
actionLabel: 'Requested review',
actionCategory: 'review',
canonicalToolName: 'review_request',
linkKinds: ['board_action'],
canLoadDetail: false,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.textContent).toContain('lead session');
expect(host.textContent).toContain('Requested review');
expect(host.textContent).toContain('tool');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -0,0 +1,550 @@
import { mkdtemp, rm, writeFile } from 'fs/promises';
import { tmpdir } from 'os';
import path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
import { BoardTaskActivityRecordBuilder } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder';
import { BoardTaskActivityTranscriptReader } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader';
import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip';
import type { TeamTask } from '../../../../../src/shared/types';
const TEAM_NAME = 'beacon-desk-2';
const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7';
const apiState = {
getTaskLogStream: vi.fn(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
apiState.getTaskLogStream(...args),
},
},
}));
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
function createTask(overrides: Partial<TeamTask> = {}): TeamTask {
return {
id: TASK_ID,
displayId: 'c414cd52',
subject: 'Help alice: fast lint/link check',
status: 'completed',
...overrides,
};
}
function createAssistantEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
agentName?: string;
sessionId?: string;
requestId?: string;
}): Record<string, unknown> {
return {
type: 'assistant',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
requestId: args.requestId,
message: {
id: `${args.uuid}-msg`,
role: 'assistant',
model: 'claude-test',
type: 'message',
stop_reason: 'tool_use',
stop_sequence: null,
usage: {
input_tokens: 0,
output_tokens: 0,
},
content: args.content,
},
};
}
function createUserEntry(args: {
uuid: string;
timestamp: string;
content: unknown[];
boardTaskLinks?: unknown[];
boardTaskToolActions?: unknown[];
toolUseResult?: unknown;
sourceToolAssistantUUID?: string;
agentName?: string;
sessionId?: string;
}): Record<string, unknown> {
return {
type: 'user',
uuid: args.uuid,
timestamp: args.timestamp,
sessionId: args.sessionId ?? 'session-tom',
teamName: TEAM_NAME,
agentName: args.agentName ?? 'tom',
isSidechain: false,
...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}),
...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}),
...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}),
...(args.sourceToolAssistantUUID
? { sourceToolAssistantUUID: args.sourceToolAssistantUUID }
: {}),
message: {
role: 'user',
content: args.content,
},
};
}
async function buildStreamResponse(transcriptPath: string) {
const task = createTask();
const transcriptReader = new BoardTaskActivityTranscriptReader();
const recordBuilder = new BoardTaskActivityRecordBuilder();
const messages = await transcriptReader.readFiles([transcriptPath]);
const recordSource = {
getTaskRecords: async () =>
recordBuilder.buildForTask({
teamName: TEAM_NAME,
targetTask: task,
tasks: [task],
messages,
}),
};
const service = new BoardTaskLogStreamService(recordSource as never);
return service.getTaskLogStream(TEAM_NAME, task.id);
}
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
describe('TaskLogStreamSection integration', () => {
const tempDirs: string[] = [];
afterEach(async () => {
document.body.innerHTML = '';
apiState.getTaskLogStream.mockReset();
vi.unstubAllGlobals();
await Promise.all(
tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })),
);
});
it('renders worker tools and does not show empty array output blocks', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-render-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const lines = [
createUserEntry({
uuid: 'u-start',
timestamp: '2026-04-12T15:36:07.747Z',
content: [
{
type: 'tool_result',
tool_use_id: 'call-task-start',
content: 'ok',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'idle',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-task-start',
canonicalToolName: 'task_start',
},
],
toolUseResult: {
toolUseId: 'call-task-start',
content: '{"id":"c414cd52"}',
},
}),
createAssistantEntry({
uuid: 'a-grep',
timestamp: '2026-04-12T15:36:14.522Z',
requestId: 'req-grep',
content: [
{
type: 'tool_use',
id: 'call-grep',
name: 'Grep',
input: {
pattern: 'ITERATION_PLAN',
path: 'docs-site',
},
},
],
}),
createUserEntry({
uuid: 'u-grep',
timestamp: '2026-04-12T15:36:14.749Z',
sourceToolAssistantUUID: 'a-grep',
content: [
{
type: 'tool_result',
tool_use_id: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-grep',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'execution',
actorContext: {
relation: 'same_task',
},
},
],
toolUseResult: {
toolUseId: 'call-grep',
content: 'docs-site/guide.md:42: ITERATION_PLAN',
},
}),
createAssistantEntry({
uuid: 'a-edit',
timestamp: '2026-04-12T15:36:40.000Z',
requestId: 'req-edit',
content: [
{
type: 'tool_use',
id: 'call-edit',
name: 'Edit',
input: {
file_path: 'docs-site/guide.md',
},
},
],
}),
createUserEntry({
uuid: 'u-edit',
timestamp: '2026-04-12T15:36:40.200Z',
sourceToolAssistantUUID: 'a-edit',
content: [
{
type: 'tool_result',
tool_use_id: 'call-edit',
content: 'File updated',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-edit',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'execution',
actorContext: {
relation: 'same_task',
},
},
],
toolUseResult: {
toolUseId: 'call-edit',
content: 'File updated',
},
}),
createAssistantEntry({
uuid: 'a-comment',
timestamp: '2026-04-12T15:47:44.500Z',
requestId: 'req-comment',
content: [
{
type: 'tool_use',
id: 'call-comment',
name: 'mcp__agent-teams__task_add_comment',
input: {
taskId: TASK_ID,
text: 'Audit complete',
},
},
],
}),
createUserEntry({
uuid: 'u-comment',
timestamp: '2026-04-12T15:47:44.773Z',
sourceToolAssistantUUID: 'a-comment',
content: [
{
type: 'tool_result',
tool_use_id: 'call-comment',
content: [
{
type: 'text',
text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}',
},
],
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'board_action',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-comment',
canonicalToolName: 'task_add_comment',
resultRefs: {
commentId: 'comment-1',
},
},
],
toolUseResult: [
{
type: 'text',
text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}',
},
],
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }),
),
);
await flushMicrotasks();
await flushMicrotasks();
});
const text = host.textContent ?? '';
expect(text).toContain('Task Log Stream');
expect(text).toContain('Grep');
expect(text).toContain('Edit');
expect(text).toContain('Claude');
expect(text).toContain('2 tool calls');
expect(text).toContain('Audit complete');
expect(text).not.toContain('[]');
expect(text).not.toContain('lead session');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('does not render empty board lifecycle payload blocks for task_start/task_complete', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-lifecycle-'));
tempDirs.push(dir);
const transcriptPath = path.join(dir, 'session.jsonl');
const lines = [
createAssistantEntry({
uuid: 'a-start',
timestamp: '2026-04-12T18:25:04.000Z',
requestId: 'req-start',
content: [
{
type: 'tool_use',
id: 'call-start',
name: 'mcp__agent-teams__task_start',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
},
},
],
}),
createUserEntry({
uuid: 'u-start',
timestamp: '2026-04-12T18:25:04.039Z',
sourceToolAssistantUUID: 'a-start',
content: [
{
type: 'tool_result',
tool_use_id: 'call-start',
content: '',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-start',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'idle',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-start',
canonicalToolName: 'task_start',
},
],
toolUseResult: {
toolUseId: 'call-start',
content: '',
},
}),
createAssistantEntry({
uuid: 'a-complete',
timestamp: '2026-04-12T18:27:04.000Z',
requestId: 'req-complete',
content: [
{
type: 'tool_use',
id: 'call-complete',
name: 'mcp__agent-teams__task_complete',
input: {
teamName: TEAM_NAME,
taskId: TASK_ID,
},
},
],
}),
createUserEntry({
uuid: 'u-complete',
timestamp: '2026-04-12T18:27:04.039Z',
sourceToolAssistantUUID: 'a-complete',
content: [
{
type: 'tool_result',
tool_use_id: 'call-complete',
content: '',
},
],
boardTaskLinks: [
{
schemaVersion: 1,
toolUseId: 'call-complete',
task: {
ref: TASK_ID,
refKind: 'canonical',
canonicalId: TASK_ID,
},
targetRole: 'subject',
linkKind: 'lifecycle',
taskArgumentSlot: 'taskId',
actorContext: {
relation: 'same_task',
},
},
],
boardTaskToolActions: [
{
schemaVersion: 1,
toolUseId: 'call-complete',
canonicalToolName: 'task_complete',
},
],
toolUseResult: {
toolUseId: 'call-complete',
content: '',
},
}),
];
await writeFile(
transcriptPath,
`${lines.map((line) => JSON.stringify(line)).join('\n')}\n`,
'utf8',
);
apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }),
),
);
await flushMicrotasks();
await flushMicrotasks();
});
const text = host.textContent ?? '';
expect(text).toContain('Task Log Stream');
expect(text).toContain('mcp__agent-teams__task_start');
expect(text).toContain('mcp__agent-teams__task_complete');
expect(text).not.toContain('[]');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -0,0 +1,107 @@
import * as os from 'os';
import * as path from 'path';
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest';
import { BoardTaskLogDiagnosticsService } from '../../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService';
import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService';
import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip';
import { setClaudeBasePathOverride } from '../../../../../src/main/utils/pathDecoder';
const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim();
const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim();
const LIVE_CLAUDE_BASE =
process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude');
const EXPECT_NO_EMPTY_PAYLOADS =
process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1';
const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '')
.split(',')
.map((value) => value.trim())
.filter(Boolean);
const describeLive =
LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip;
const apiState = {
getTaskLogStream: vi.fn(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
apiState.getTaskLogStream(...args),
},
},
}));
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
describeLive('TaskLogStreamSection live smoke', () => {
beforeAll(() => {
setClaudeBasePathOverride(LIVE_CLAUDE_BASE);
});
afterAll(() => {
setClaudeBasePathOverride(null);
});
afterEach(() => {
document.body.innerHTML = '';
apiState.getTaskLogStream.mockReset();
vi.unstubAllGlobals();
});
it('renders the current live task log stream without empty payload placeholders', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const diagnosticsService = new BoardTaskLogDiagnosticsService();
const streamService = new BoardTaskLogStreamService();
const report = await diagnosticsService.diagnose(LIVE_TEAM!, LIVE_TASK!);
const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId);
apiState.getTaskLogStream.mockResolvedValueOnce(stream);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(
TooltipProvider,
null,
React.createElement(TaskLogStreamSection, {
teamName: LIVE_TEAM!,
taskId: report.task.taskId,
}),
),
);
await flushMicrotasks();
await flushMicrotasks();
});
expect(host.textContent).toContain('Task Log Stream');
expect(host.textContent).not.toContain('Loading task log stream');
expect(host.textContent).not.toContain('[]');
if (EXPECT_NO_EMPTY_PAYLOADS) {
expect(report.stream.emptyPayloadExamples).toHaveLength(0);
}
for (const toolName of EXPECT_VISIBLE_TOOLS) {
expect(host.textContent).toContain(toolName);
}
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});

View file

@ -0,0 +1,223 @@
import React, { act } from 'react';
import { createRoot } from 'react-dom/client';
import { afterEach, describe, expect, it, vi } from 'vitest';
import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types';
const apiState = {
getTaskLogStream: vi.fn<
(teamName: string, taskId: string) => Promise<BoardTaskLogStreamResponse>
>(),
};
vi.mock('@renderer/api', () => ({
api: {
teams: {
getTaskLogStream: (...args: Parameters<typeof apiState.getTaskLogStream>) =>
apiState.getTaskLogStream(...args),
},
},
}));
vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({
MemberExecutionLog: ({
memberName,
chunks,
}: {
memberName?: string;
chunks: { id: string }[];
}) =>
React.createElement(
'div',
{ 'data-testid': 'member-execution-log' },
`${memberName ?? 'lead'}:${chunks.length}`
),
}));
import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection';
function flushMicrotasks(): Promise<void> {
return Promise.resolve();
}
describe('TaskLogStreamSection', () => {
afterEach(() => {
document.body.innerHTML = '';
apiState.getTaskLogStream.mockReset();
vi.unstubAllGlobals();
});
it('renders empty state when the stream is absent', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [],
defaultFilter: 'all',
segments: [],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.textContent).toContain('Task Log Stream');
expect(host.textContent).toContain('No task log stream yet');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('shows participant chips and filters the visible segments', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [
{
key: 'member:tom',
label: 'tom',
role: 'member',
isLead: false,
isSidechain: true,
},
{
key: 'member:alice',
label: 'alice',
role: 'member',
isLead: false,
isSidechain: true,
},
],
defaultFilter: 'all',
segments: [
{
id: 'segment-tom-1',
participantKey: 'member:tom',
actor: {
memberName: 'tom',
role: 'member',
sessionId: 'session-tom-1',
agentId: 'agent-tom',
isSidechain: true,
},
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
chunks: [{ id: 'chunk-tom-1', chunkType: 'user', rawMessages: [] }] as never,
},
{
id: 'segment-alice-1',
participantKey: 'member:alice',
actor: {
memberName: 'alice',
role: 'member',
sessionId: 'session-alice-1',
agentId: 'agent-alice',
isSidechain: true,
},
startTimestamp: '2026-04-12T16:02:00.000Z',
endTimestamp: '2026-04-12T16:03:00.000Z',
chunks: [{ id: 'chunk-alice-1', chunkType: 'user', rawMessages: [] }] as never,
},
{
id: 'segment-tom-2',
participantKey: 'member:tom',
actor: {
memberName: 'tom',
role: 'member',
sessionId: 'session-tom-2',
agentId: 'agent-tom',
isSidechain: true,
},
startTimestamp: '2026-04-12T16:04:00.000Z',
endTimestamp: '2026-04-12T16:05:00.000Z',
chunks: [{ id: 'chunk-tom-2', chunkType: 'user', rawMessages: [] }] as never,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.textContent).toContain('All');
expect(host.textContent).toContain('tom');
expect(host.textContent).toContain('alice');
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(3);
const buttons = [...host.querySelectorAll('button')];
const tomButton = buttons.find((button) => button.textContent?.trim() === 'tom');
expect(tomButton).toBeDefined();
await act(async () => {
tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
await flushMicrotasks();
});
const logs = [...host.querySelectorAll('[data-testid="member-execution-log"]')].map(
(node) => node.textContent
);
expect(logs).toEqual(['tom:1', 'tom:1']);
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
it('honors a participant default filter from the stream response', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiState.getTaskLogStream.mockResolvedValueOnce({
participants: [
{
key: 'member:tom',
label: 'tom',
role: 'member',
isLead: false,
isSidechain: false,
},
],
defaultFilter: 'member:tom',
segments: [
{
id: 'segment-tom-1',
participantKey: 'member:tom',
actor: {
memberName: 'tom',
role: 'lead',
sessionId: 'session-tom-1',
isSidechain: false,
},
startTimestamp: '2026-04-12T16:00:00.000Z',
endTimestamp: '2026-04-12T16:01:00.000Z',
chunks: [{ id: 'chunk-tom-1', chunkType: 'ai', rawMessages: [] }] as never,
},
],
});
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' }));
await flushMicrotasks();
});
expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(1);
expect(host.textContent).toContain('tom:1');
await act(async () => {
root.unmount();
await flushMicrotasks();
});
});
});