feat(task-logs): add board task activity and task log stream
This commit is contained in:
parent
57c384531a
commit
32cea2a927
68 changed files with 14114 additions and 74 deletions
|
|
@ -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**: заранее фиксируем критерии готовности и ручную проверку
|
||||
|
||||
|
|
|
|||
2630
docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
Normal file
2630
docs/iterations/iteration-07-task-logs-explicit-board-task-links.md
Normal file
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
192
docs/iterations/schemas/board-task-transcript-v1.schema.json
Normal file
192
docs/iterations/schemas/board-task-transcript-v1.schema.json
Normal 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
|
||||
}
|
||||
92
scripts/diagnose-task-log-stream.ts
Normal file
92
scripts/diagnose-task-log-stream.ts
Normal 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;
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
43
src/main/services/team/agentTeamsToolNames.ts
Normal file
43
src/main/services/team/agentTeamsToolNames.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
18
src/main/services/team/taskLogs/activity/featureGates.ts
Normal file
18
src/main/services/team/taskLogs/activity/featureGates.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
}
|
||||
}
|
||||
18
src/main/services/team/taskLogs/exact/featureGates.ts
Normal file
18
src/main/services/team/taskLogs/exact/featureGates.ts
Normal 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);
|
||||
}
|
||||
33
src/main/services/team/taskLogs/exact/fileVersions.ts
Normal file
33
src/main/services/team/taskLogs/exact/fileVersions.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
|
|
|
|||
132
src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
Normal file
132
src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
262
src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
Normal file
262
src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
211
src/renderer/components/team/taskLogs/TaskActivitySection.tsx
Normal file
211
src/renderer/components/team/taskLogs/TaskActivitySection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
222
src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
Normal file
222
src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
src/renderer/components/team/taskLogs/TaskLogsPanel.tsx
Normal file
55
src/renderer/components/team/taskLogs/TaskLogsPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
src/renderer/components/team/taskLogs/featureGates.ts
Normal file
22
src/renderer/components/team/taskLogs/featureGates.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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[]>;
|
||||
|
|
|
|||
128
src/shared/utils/boardTaskActivityLabels.ts
Normal file
128
src/shared/utils/boardTaskActivityLabels.ts
Normal 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';
|
||||
}
|
||||
}
|
||||
|
|
@ -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)!;
|
||||
|
|
|
|||
427
test/main/services/team/BoardTaskActivityEntryBuilder.test.ts
Normal file
427
test/main/services/team/BoardTaskActivityEntryBuilder.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
305
test/main/services/team/BoardTaskExactLogDetailSelector.test.ts
Normal file
305
test/main/services/team/BoardTaskExactLogDetailSelector.test.ts
Normal 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',
|
||||
});
|
||||
});
|
||||
});
|
||||
212
test/main/services/team/BoardTaskExactLogDetailService.test.ts
Normal file
212
test/main/services/team/BoardTaskExactLogDetailService.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
145
test/main/services/team/BoardTaskExactLogSummarySelector.test.ts
Normal file
145
test/main/services/team/BoardTaskExactLogSummarySelector.test.ts
Normal 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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
82
test/main/services/team/BoardTaskExactLogsService.test.ts
Normal file
82
test/main/services/team/BoardTaskExactLogsService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
311
test/main/services/team/BoardTaskLogDiagnosticsService.test.ts
Normal file
311
test/main/services/team/BoardTaskLogDiagnosticsService.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
72
test/main/services/team/BoardTaskLogStream.live.test.ts
Normal file
72
test/main/services/team/BoardTaskLogStream.live.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
380
test/main/services/team/BoardTaskLogStreamIntegration.test.ts
Normal file
380
test/main/services/team/BoardTaskLogStreamIntegration.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
639
test/main/services/team/BoardTaskLogStreamService.test.ts
Normal file
639
test/main/services/team/BoardTaskLogStreamService.test.ts
Normal 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' });
|
||||
});
|
||||
});
|
||||
196
test/main/services/team/BoardTaskTranscriptContract.test.ts
Normal file
196
test/main/services/team/BoardTaskTranscriptContract.test.ts
Normal 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' },
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
37
test/renderer/api/httpClient.exactTaskLogs.test.ts
Normal file
37
test/renderer/api/httpClient.exactTaskLogs.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue