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)
|
- [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md)
|
||||||
- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md)
|
- [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md)
|
||||||
- [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.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)
|
- **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI)
|
||||||
- **Чёткий scope**: у каждой итерации есть цели и не‑цели
|
- **Чёткий scope**: у каждой итерации есть цели и не‑цели
|
||||||
- **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку
|
- **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 {
|
import type {
|
||||||
BranchStatusService,
|
BranchStatusService,
|
||||||
|
BoardTaskActivityService,
|
||||||
|
BoardTaskLogStreamService,
|
||||||
|
BoardTaskExactLogDetailService,
|
||||||
|
BoardTaskExactLogsService,
|
||||||
ChangeExtractorService,
|
ChangeExtractorService,
|
||||||
CliInstallerService,
|
CliInstallerService,
|
||||||
FileContentResolver,
|
FileContentResolver,
|
||||||
|
|
@ -130,6 +134,10 @@ export function initializeIpcHandlers(
|
||||||
teamProvisioningService: TeamProvisioningService,
|
teamProvisioningService: TeamProvisioningService,
|
||||||
teamMemberLogsFinder: TeamMemberLogsFinder,
|
teamMemberLogsFinder: TeamMemberLogsFinder,
|
||||||
memberStatsComputer: MemberStatsComputer,
|
memberStatsComputer: MemberStatsComputer,
|
||||||
|
boardTaskActivityService: BoardTaskActivityService,
|
||||||
|
boardTaskLogStreamService: BoardTaskLogStreamService,
|
||||||
|
boardTaskExactLogsService: BoardTaskExactLogsService,
|
||||||
|
boardTaskExactLogDetailService: BoardTaskExactLogDetailService,
|
||||||
teammateToolTracker: TeammateToolTracker | undefined,
|
teammateToolTracker: TeammateToolTracker | undefined,
|
||||||
branchStatusService: BranchStatusService | undefined,
|
branchStatusService: BranchStatusService | undefined,
|
||||||
contextCallbacks: {
|
contextCallbacks: {
|
||||||
|
|
@ -174,7 +182,11 @@ export function initializeIpcHandlers(
|
||||||
memberStatsComputer,
|
memberStatsComputer,
|
||||||
teamBackupService,
|
teamBackupService,
|
||||||
teammateToolTracker,
|
teammateToolTracker,
|
||||||
branchStatusService
|
branchStatusService,
|
||||||
|
boardTaskActivityService,
|
||||||
|
boardTaskLogStreamService,
|
||||||
|
boardTaskExactLogsService,
|
||||||
|
boardTaskExactLogDetailService
|
||||||
);
|
);
|
||||||
initializeConfigHandlers({
|
initializeConfigHandlers({
|
||||||
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated,
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,14 @@ import {
|
||||||
TEAM_GET_CLAUDE_LOGS,
|
TEAM_GET_CLAUDE_LOGS,
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
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_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_GET_SAVED_REQUEST,
|
TEAM_GET_SAVED_REQUEST,
|
||||||
TEAM_GET_TASK_ATTACHMENT,
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
|
|
@ -98,15 +102,15 @@ import {
|
||||||
buildActionModeAgentBlock,
|
buildActionModeAgentBlock,
|
||||||
isAgentActionMode,
|
isAgentActionMode,
|
||||||
} from '../services/team/actionModeInstructions';
|
} from '../services/team/actionModeInstructions';
|
||||||
|
import {
|
||||||
|
buildReplaceMembersDiff,
|
||||||
|
buildReplaceMembersSummaryMessage,
|
||||||
|
} from '../services/team/memberUpdateNotifications';
|
||||||
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore';
|
||||||
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
import { TeamMetaStore } from '../services/team/TeamMetaStore';
|
||||||
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService';
|
||||||
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore';
|
||||||
import {
|
|
||||||
buildReplaceMembersDiff,
|
|
||||||
buildReplaceMembersSummaryMessage,
|
|
||||||
} from '../services/team/memberUpdateNotifications';
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
validateFromField,
|
validateFromField,
|
||||||
|
|
@ -118,6 +122,10 @@ import {
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
BranchStatusService,
|
BranchStatusService,
|
||||||
|
BoardTaskActivityService,
|
||||||
|
BoardTaskLogStreamService,
|
||||||
|
BoardTaskExactLogDetailService,
|
||||||
|
BoardTaskExactLogsService,
|
||||||
MemberStatsComputer,
|
MemberStatsComputer,
|
||||||
TeamDataService,
|
TeamDataService,
|
||||||
TeammateToolTracker,
|
TeammateToolTracker,
|
||||||
|
|
@ -131,6 +139,10 @@ import type {
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
AttachmentMeta,
|
AttachmentMeta,
|
||||||
AttachmentPayload,
|
AttachmentPayload,
|
||||||
|
BoardTaskActivityEntry,
|
||||||
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskExactLogDetailResult,
|
||||||
|
BoardTaskExactLogSummariesResponse,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
EffortLevel,
|
EffortLevel,
|
||||||
GlobalTask,
|
GlobalTask,
|
||||||
|
|
@ -143,6 +155,7 @@ import type {
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusEntry,
|
MemberSpawnStatusEntry,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
|
MessagesPage,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
|
|
@ -155,7 +168,6 @@ import type {
|
||||||
TeamCreateRequest,
|
TeamCreateRequest,
|
||||||
TeamCreateResponse,
|
TeamCreateResponse,
|
||||||
TeamData,
|
TeamData,
|
||||||
MessagesPage,
|
|
||||||
TeamLaunchRequest,
|
TeamLaunchRequest,
|
||||||
TeamLaunchResponse,
|
TeamLaunchResponse,
|
||||||
TeamMessageNotificationData,
|
TeamMessageNotificationData,
|
||||||
|
|
@ -184,7 +196,7 @@ const SEEN_RATE_LIMIT_KEYS_MAX = 500;
|
||||||
async function getDurableLeadTeammateRoster(
|
async function getDurableLeadTeammateRoster(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: 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 normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? '';
|
||||||
const leadLower = normalize(leadName);
|
const leadLower = normalize(leadName);
|
||||||
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
|
const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0));
|
||||||
|
|
@ -241,7 +253,7 @@ async function getDurableLeadTeammateRoster(
|
||||||
function buildLeadRosterContextBlock(
|
function buildLeadRosterContextBlock(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
leadName: string,
|
leadName: string,
|
||||||
teammates: Array<{ name: string; role?: string }>
|
teammates: { name: string; role?: string }[]
|
||||||
): string | null {
|
): string | null {
|
||||||
if (teammates.length === 0) return null;
|
if (teammates.length === 0) return null;
|
||||||
|
|
||||||
|
|
@ -377,6 +389,10 @@ let memberStatsComputer: MemberStatsComputer | null = null;
|
||||||
let teamBackupService: TeamBackupService | null = null;
|
let teamBackupService: TeamBackupService | null = null;
|
||||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||||
let branchStatusService: BranchStatusService | 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 attachmentStore = new TeamAttachmentStore();
|
||||||
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
const taskAttachmentStore = new TeamTaskAttachmentStore();
|
||||||
|
|
@ -407,7 +423,11 @@ export function initializeTeamHandlers(
|
||||||
statsComputer?: MemberStatsComputer,
|
statsComputer?: MemberStatsComputer,
|
||||||
backupService?: TeamBackupService,
|
backupService?: TeamBackupService,
|
||||||
toolTracker?: TeammateToolTracker,
|
toolTracker?: TeammateToolTracker,
|
||||||
branchTracker?: BranchStatusService
|
branchTracker?: BranchStatusService,
|
||||||
|
taskActivityService?: BoardTaskActivityService,
|
||||||
|
taskLogStreamService?: BoardTaskLogStreamService,
|
||||||
|
taskExactLogsService?: BoardTaskExactLogsService,
|
||||||
|
taskExactLogDetailService?: BoardTaskExactLogDetailService
|
||||||
): void {
|
): void {
|
||||||
teamDataService = service;
|
teamDataService = service;
|
||||||
teamProvisioningService = provisioningService;
|
teamProvisioningService = provisioningService;
|
||||||
|
|
@ -416,6 +436,10 @@ export function initializeTeamHandlers(
|
||||||
teamBackupService = backupService ?? null;
|
teamBackupService = backupService ?? null;
|
||||||
teammateToolTracker = toolTracker ?? null;
|
teammateToolTracker = toolTracker ?? null;
|
||||||
branchStatusService = branchTracker ?? null;
|
branchStatusService = branchTracker ?? null;
|
||||||
|
boardTaskActivityService = taskActivityService ?? null;
|
||||||
|
boardTaskLogStreamService = taskLogStreamService ?? null;
|
||||||
|
boardTaskExactLogsService = taskExactLogsService ?? null;
|
||||||
|
boardTaskExactLogDetailService = taskExactLogDetailService ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function registerTeamHandlers(ipcMain: IpcMain): void {
|
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_CREATE_CONFIG, handleCreateConfig);
|
||||||
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs);
|
||||||
ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask);
|
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_GET_MEMBER_STATS, handleGetMemberStats);
|
||||||
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig);
|
||||||
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
ipcMain.handle(TEAM_START_TASK, handleStartTask);
|
||||||
|
|
@ -517,6 +545,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
||||||
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
ipcMain.removeHandler(TEAM_CREATE_CONFIG);
|
||||||
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS);
|
||||||
ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK);
|
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_GET_MEMBER_STATS);
|
||||||
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
ipcMain.removeHandler(TEAM_UPDATE_CONFIG);
|
||||||
ipcMain.removeHandler(TEAM_START_TASK);
|
ipcMain.removeHandler(TEAM_START_TASK);
|
||||||
|
|
@ -579,6 +611,34 @@ function getBranchStatusService(): BranchStatusService {
|
||||||
return 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>(
|
async function wrapTeamHandler<T>(
|
||||||
operation: string,
|
operation: string,
|
||||||
handler: () => Promise<T>
|
handler: () => Promise<T>
|
||||||
|
|
@ -1371,7 +1431,7 @@ async function handlePrepareProvisioning(
|
||||||
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
): Promise<IpcResult<TeamProvisioningPrepareResult>> {
|
||||||
let validatedCwd: string | undefined;
|
let validatedCwd: string | undefined;
|
||||||
let validatedProviderId: TeamLaunchRequest['providerId'];
|
let validatedProviderId: TeamLaunchRequest['providerId'];
|
||||||
let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined;
|
let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined;
|
||||||
if (cwd !== undefined) {
|
if (cwd !== undefined) {
|
||||||
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
if (typeof cwd !== 'string' || cwd.trim().length === 0) {
|
||||||
return { success: false, error: 'cwd must be a non-empty string' };
|
return { success: false, error: 'cwd must be a non-empty string' };
|
||||||
|
|
@ -1391,7 +1451,7 @@ async function handlePrepareProvisioning(
|
||||||
if (!Array.isArray(providerIds)) {
|
if (!Array.isArray(providerIds)) {
|
||||||
return { success: false, error: 'providerIds must be an array when provided' };
|
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) {
|
for (const entry of providerIds) {
|
||||||
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
|
if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') {
|
||||||
return { success: false, error: 'providerIds entries must be anthropic, codex, or 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 {
|
function getMemberStatsComputer(): MemberStatsComputer {
|
||||||
if (!memberStatsComputer) {
|
if (!memberStatsComputer) {
|
||||||
throw new Error('Member stats computer is not initialized');
|
throw new Error('Member stats computer is not initialized');
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,11 @@ import { createReadStream } from 'fs';
|
||||||
import { stat } from 'fs/promises';
|
import { stat } from 'fs/promises';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
import {
|
||||||
|
canonicalizeAgentTeamsToolName,
|
||||||
|
isAgentTeamsTaskBoundaryToolName,
|
||||||
|
} from './agentTeamsToolNames';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
TaskBoundariesResult,
|
TaskBoundariesResult,
|
||||||
TaskBoundary,
|
TaskBoundary,
|
||||||
|
|
@ -31,8 +36,6 @@ interface ToolUseInfo {
|
||||||
filePath?: string;
|
filePath?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']);
|
|
||||||
|
|
||||||
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none';
|
||||||
|
|
||||||
function extractTaskId(input: Record<string, unknown>): string {
|
function extractTaskId(input: Record<string, unknown>): string {
|
||||||
|
|
@ -102,7 +105,7 @@ export class TaskBoundaryParser {
|
||||||
const b = block as Record<string, unknown>;
|
const b = block as Record<string, unknown>;
|
||||||
if (b.type !== 'tool_use') continue;
|
if (b.type !== 'tool_use') continue;
|
||||||
const rawName = typeof b.name === 'string' ? b.name : '';
|
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 toolUseId = typeof b.id === 'string' ? b.id : '';
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
const fp = typeof input?.file_path === 'string' ? input.file_path : 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;
|
if (b.type !== 'tool_use') continue;
|
||||||
|
|
||||||
const rawName = typeof b.name === 'string' ? b.name : '';
|
const rawName = typeof b.name === 'string' ? b.name : '';
|
||||||
const toolName = rawName.replace(/^proxy_/, '');
|
const toolName = canonicalizeAgentTeamsToolName(rawName);
|
||||||
if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue;
|
if (!isAgentTeamsTaskBoundaryToolName(toolName)) continue;
|
||||||
|
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
if (!input) continue;
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,10 @@ import * as readline from 'readline';
|
||||||
import { TeamConfigReader } from './TeamConfigReader';
|
import { TeamConfigReader } from './TeamConfigReader';
|
||||||
import { TeamInboxReader } from './TeamInboxReader';
|
import { TeamInboxReader } from './TeamInboxReader';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
|
import {
|
||||||
|
canonicalizeAgentTeamsToolName,
|
||||||
|
lineHasAgentTeamsTaskBoundaryToolName,
|
||||||
|
} from './agentTeamsToolNames';
|
||||||
|
|
||||||
import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
|
import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -684,7 +688,7 @@ export class TeamMemberLogsFinder {
|
||||||
|
|
||||||
async listAttributedSubagentFiles(
|
async listAttributedSubagentFiles(
|
||||||
teamName: string
|
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);
|
const discovery = await this.discoverProjectSessions(teamName);
|
||||||
if (!discovery) return [];
|
if (!discovery) return [];
|
||||||
|
|
||||||
|
|
@ -700,12 +704,12 @@ export class TeamMemberLogsFinder {
|
||||||
? [currentLeadSessionId]
|
? [currentLeadSessionId]
|
||||||
: sessionIds;
|
: sessionIds;
|
||||||
const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds);
|
const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds);
|
||||||
const results: Array<{
|
const results: {
|
||||||
memberName: string;
|
memberName: string;
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
filePath: string;
|
filePath: string;
|
||||||
mtimeMs: number;
|
mtimeMs: number;
|
||||||
}> = [];
|
}[] = [];
|
||||||
|
|
||||||
const settled = await Promise.all(
|
const settled = await Promise.all(
|
||||||
candidates.map(async (candidate) => {
|
candidates.map(async (candidate) => {
|
||||||
|
|
@ -764,12 +768,7 @@ export class TeamMemberLogsFinder {
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
if (
|
if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) {
|
||||||
(line.includes('"task_start"') ||
|
|
||||||
line.includes('"task_complete"') ||
|
|
||||||
line.includes('"task_set_status"')) &&
|
|
||||||
pattern.test(line)
|
|
||||||
) {
|
|
||||||
rl.close();
|
rl.close();
|
||||||
stream.destroy();
|
stream.destroy();
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -1146,13 +1145,9 @@ export class TeamMemberLogsFinder {
|
||||||
// Skip read-only task tools — they reference taskId but don't indicate
|
// Skip read-only task tools — they reference taskId but don't indicate
|
||||||
// that this session actually WORKED on the task. Agents commonly call
|
// that this session actually WORKED on the task. Agents commonly call
|
||||||
// task_get to check dependencies from other tasks, producing false matches.
|
// task_get to check dependencies from other tasks, producing false matches.
|
||||||
const toolName = typeof b.name === 'string' ? b.name : '';
|
const rawToolName = typeof b.name === 'string' ? b.name : '';
|
||||||
if (
|
const toolName = canonicalizeAgentTeamsToolName(rawToolName);
|
||||||
toolName === 'task_get' ||
|
if (toolName === 'task_get' || toolName === 'TaskGet') continue;
|
||||||
toolName === 'mcp__agent-teams__task_get' ||
|
|
||||||
toolName === 'TaskGet'
|
|
||||||
)
|
|
||||||
continue;
|
|
||||||
|
|
||||||
const input = b.input as Record<string, unknown> | undefined;
|
const input = b.input as Record<string, unknown> | undefined;
|
||||||
if (!input) continue;
|
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 { 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 { CascadeGuard } from './CascadeGuard';
|
||||||
export { ChangeExtractorService } from './ChangeExtractorService';
|
export { ChangeExtractorService } from './ChangeExtractorService';
|
||||||
export { ClaudeBinaryResolver } from './ClaudeBinaryResolver';
|
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 */
|
/** Get session logs that reference a task */
|
||||||
export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask';
|
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) */
|
/** Update team config (name, description) */
|
||||||
export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
|
export const TEAM_UPDATE_CONFIG = 'team:updateConfig';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,11 @@ import {
|
||||||
API_KEYS_SAVE,
|
API_KEYS_SAVE,
|
||||||
API_KEYS_STORAGE_STATUS,
|
API_KEYS_STORAGE_STATUS,
|
||||||
APP_RELAUNCH,
|
APP_RELAUNCH,
|
||||||
CLI_INSTALLER_GET_STATUS,
|
|
||||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||||
|
CLI_INSTALLER_GET_STATUS,
|
||||||
CLI_INSTALLER_INSTALL,
|
CLI_INSTALLER_INSTALL,
|
||||||
CLI_INSTALLER_INVALIDATE_STATUS,
|
CLI_INSTALLER_INVALIDATE_STATUS,
|
||||||
CLI_INSTALLER_PROGRESS,
|
CLI_INSTALLER_PROGRESS,
|
||||||
TMUX_GET_STATUS,
|
|
||||||
CONTEXT_CHANGED,
|
CONTEXT_CHANGED,
|
||||||
CONTEXT_GET_ACTIVE,
|
CONTEXT_GET_ACTIVE,
|
||||||
CONTEXT_LIST,
|
CONTEXT_LIST,
|
||||||
|
|
@ -125,8 +124,13 @@ import {
|
||||||
TEAM_GET_DATA,
|
TEAM_GET_DATA,
|
||||||
TEAM_GET_DELETED_TASKS,
|
TEAM_GET_DELETED_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
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_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
|
TEAM_GET_MESSAGES_PAGE,
|
||||||
TEAM_GET_PROJECT_BRANCH,
|
TEAM_GET_PROJECT_BRANCH,
|
||||||
TEAM_GET_SAVED_REQUEST,
|
TEAM_GET_SAVED_REQUEST,
|
||||||
TEAM_GET_TASK_ATTACHMENT,
|
TEAM_GET_TASK_ATTACHMENT,
|
||||||
|
|
@ -152,7 +156,6 @@ import {
|
||||||
TEAM_RESTORE_TASK,
|
TEAM_RESTORE_TASK,
|
||||||
TEAM_SAVE_TASK_ATTACHMENT,
|
TEAM_SAVE_TASK_ATTACHMENT,
|
||||||
TEAM_SEND_MESSAGE,
|
TEAM_SEND_MESSAGE,
|
||||||
TEAM_GET_MESSAGES_PAGE,
|
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
TEAM_SET_PROJECT_BRANCH_TRACKING,
|
||||||
TEAM_SET_TASK_CLARIFICATION,
|
TEAM_SET_TASK_CLARIFICATION,
|
||||||
|
|
@ -180,6 +183,7 @@ import {
|
||||||
TERMINAL_RESIZE,
|
TERMINAL_RESIZE,
|
||||||
TERMINAL_SPAWN,
|
TERMINAL_SPAWN,
|
||||||
TERMINAL_WRITE,
|
TERMINAL_WRITE,
|
||||||
|
TMUX_GET_STATUS,
|
||||||
UPDATER_CHECK,
|
UPDATER_CHECK,
|
||||||
UPDATER_DOWNLOAD,
|
UPDATER_DOWNLOAD,
|
||||||
UPDATER_INSTALL,
|
UPDATER_INSTALL,
|
||||||
|
|
@ -228,6 +232,10 @@ import type {
|
||||||
ApplyReviewRequest,
|
ApplyReviewRequest,
|
||||||
ApplyReviewResult,
|
ApplyReviewResult,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
BoardTaskActivityEntry,
|
||||||
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskExactLogDetailResult,
|
||||||
|
BoardTaskExactLogSummariesResponse,
|
||||||
ChangeStats,
|
ChangeStats,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -252,6 +260,7 @@ import type {
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
|
MessagesPage,
|
||||||
NotificationTrigger,
|
NotificationTrigger,
|
||||||
ProjectBranchChangeEvent,
|
ProjectBranchChangeEvent,
|
||||||
RejectResult,
|
RejectResult,
|
||||||
|
|
@ -261,7 +270,6 @@ import type {
|
||||||
ScheduleRun,
|
ScheduleRun,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
MessagesPage,
|
|
||||||
SessionsByIdsOptions,
|
SessionsByIdsOptions,
|
||||||
SessionsPaginationOptions,
|
SessionsPaginationOptions,
|
||||||
SnippetDiff,
|
SnippetDiff,
|
||||||
|
|
@ -290,10 +298,10 @@ import type {
|
||||||
TeamTask,
|
TeamTask,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
TeamUpdateConfigRequest,
|
TeamUpdateConfigRequest,
|
||||||
|
TmuxStatus,
|
||||||
ToolApprovalEvent,
|
ToolApprovalEvent,
|
||||||
ToolApprovalFileContent,
|
ToolApprovalFileContent,
|
||||||
ToolApprovalSettings,
|
ToolApprovalSettings,
|
||||||
TmuxStatus,
|
|
||||||
TriggerTestResult,
|
TriggerTestResult,
|
||||||
UpdateKanbanPatch,
|
UpdateKanbanPatch,
|
||||||
UpdateSchedulePatch,
|
UpdateSchedulePatch,
|
||||||
|
|
@ -954,6 +962,41 @@ const electronAPI: ElectronAPI = {
|
||||||
options
|
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) => {
|
getMemberStats: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
|
return invokeIpcWithResult<MemberFullStats>(TEAM_GET_MEMBER_STATS, teamName, memberName);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@
|
||||||
import type {
|
import type {
|
||||||
AppConfig,
|
AppConfig,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskExactLogDetailResult,
|
||||||
|
BoardTaskExactLogSummariesResponse,
|
||||||
ClaudeMdFileInfo,
|
ClaudeMdFileInfo,
|
||||||
ClaudeRootFolderSelection,
|
ClaudeRootFolderSelection,
|
||||||
ClaudeRootInfo,
|
ClaudeRootInfo,
|
||||||
|
|
@ -804,6 +807,26 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
getLogsForTask: async () => {
|
getLogsForTask: async () => {
|
||||||
return [];
|
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 () => {
|
getMemberStats: async () => {
|
||||||
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
|
console.warn('[HttpAPIClient] getMemberStats is not available in browser mode');
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection';
|
||||||
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
import { FileIcon } from '@renderer/components/team/editor/FileIcon';
|
||||||
import { MemberBadge } from '@renderer/components/team/MemberBadge';
|
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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Button } from '@renderer/components/ui/button';
|
import { Button } from '@renderer/components/ui/button';
|
||||||
import {
|
import {
|
||||||
|
|
@ -1256,29 +1256,8 @@ export const TaskDetailDialog = ({
|
||||||
{variant === 'team' ? (
|
{variant === 'team' ? (
|
||||||
<CollapsibleTeamSection
|
<CollapsibleTeamSection
|
||||||
key={`task-logs:${currentTask.id}`}
|
key={`task-logs:${currentTask.id}`}
|
||||||
title="Execution Logs"
|
title="Task Logs"
|
||||||
icon={<ScrollText size={14} />}
|
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"
|
contentClassName="pl-2.5 overflow-visible"
|
||||||
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
headerClassName="-mx-6 w-[calc(100%+3rem)]"
|
||||||
headerContentClassName="pl-6"
|
headerContentClassName="pl-6"
|
||||||
|
|
@ -1286,19 +1265,14 @@ export const TaskDetailDialog = ({
|
||||||
keepMounted
|
keepMounted
|
||||||
>
|
>
|
||||||
<div className="min-w-0">
|
<div className="min-w-0">
|
||||||
<MemberLogsTab
|
<TaskLogsPanel
|
||||||
teamName={teamName}
|
teamName={teamName}
|
||||||
taskId={currentTask.id}
|
task={currentTask}
|
||||||
taskOwner={currentTask.owner}
|
|
||||||
taskStatus={currentTask.status}
|
|
||||||
taskWorkIntervals={currentTask.workIntervals}
|
|
||||||
taskSince={taskSince}
|
taskSince={taskSince}
|
||||||
|
isExecutionRefreshing={logsRefreshing}
|
||||||
|
isExecutionPreviewOnline={executionPreviewOnline}
|
||||||
onRefreshingChange={setLogsRefreshing}
|
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}
|
showSubagentPreview={Boolean(currentTask.owner) && !isLeadOwnedTask}
|
||||||
// Temporary debug option: for lead-owned tasks, show quick preview from lead session.
|
|
||||||
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
showLeadPreview={allowLeadExecutionPreview && isLeadOwnedTask}
|
||||||
onPreviewOnlineChange={setExecutionPreviewOnline}
|
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';
|
} from './schedule';
|
||||||
import type {
|
import type {
|
||||||
AddMemberRequest,
|
AddMemberRequest,
|
||||||
|
BoardTaskActivityEntry,
|
||||||
|
BoardTaskLogStreamResponse,
|
||||||
|
BoardTaskExactLogDetailResult,
|
||||||
|
BoardTaskExactLogSummariesResponse,
|
||||||
AddTaskCommentRequest,
|
AddTaskCommentRequest,
|
||||||
AttachmentFileData,
|
AttachmentFileData,
|
||||||
CreateTaskRequest,
|
CreateTaskRequest,
|
||||||
|
|
@ -51,11 +55,11 @@ import type {
|
||||||
MemberFullStats,
|
MemberFullStats,
|
||||||
MemberLogSummary,
|
MemberLogSummary,
|
||||||
MemberSpawnStatusesSnapshot,
|
MemberSpawnStatusesSnapshot,
|
||||||
|
MessagesPage,
|
||||||
ProjectBranchChangeEvent,
|
ProjectBranchChangeEvent,
|
||||||
ReplaceMembersRequest,
|
ReplaceMembersRequest,
|
||||||
SendMessageRequest,
|
SendMessageRequest,
|
||||||
SendMessageResult,
|
SendMessageResult,
|
||||||
MessagesPage,
|
|
||||||
TaskAttachmentMeta,
|
TaskAttachmentMeta,
|
||||||
TaskChangePresenceState,
|
TaskChangePresenceState,
|
||||||
TaskComment,
|
TaskComment,
|
||||||
|
|
@ -477,6 +481,18 @@ export interface TeamsAPI {
|
||||||
since?: string;
|
since?: string;
|
||||||
}
|
}
|
||||||
) => Promise<MemberLogSummary[]>;
|
) => 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>;
|
getMemberStats: (teamName: string, memberName: string) => Promise<MemberFullStats>;
|
||||||
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
launchTeam: (request: TeamLaunchRequest) => Promise<TeamLaunchResponse>;
|
||||||
getAllTasks: () => Promise<GlobalTask[]>;
|
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 * as os from 'os';
|
||||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
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', () => ({
|
vi.mock('electron', () => ({
|
||||||
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') },
|
||||||
|
|
@ -64,6 +72,10 @@ import {
|
||||||
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
TEAM_SET_CHANGE_PRESENCE_TRACKING,
|
||||||
TEAM_GET_ALL_TASKS,
|
TEAM_GET_ALL_TASKS,
|
||||||
TEAM_GET_LOGS_FOR_TASK,
|
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_LOGS,
|
||||||
TEAM_GET_MEMBER_STATS,
|
TEAM_GET_MEMBER_STATS,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
|
|
@ -186,6 +198,25 @@ describe('ipc teams handlers', () => {
|
||||||
getLeadActivityState: vi.fn(() => 'idle'),
|
getLeadActivityState: vi.fn(() => 'idle'),
|
||||||
stopTeam: vi.fn(() => undefined),
|
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(() => {
|
beforeEach(() => {
|
||||||
handlers.clear();
|
handlers.clear();
|
||||||
|
|
@ -195,7 +226,19 @@ describe('ipc teams handlers', () => {
|
||||||
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
mockTeamDataWorkerClient.isAvailable.mockReturnValue(false);
|
||||||
mockTeamDataWorkerClient.getTeamData.mockReset();
|
mockTeamDataWorkerClient.getTeamData.mockReset();
|
||||||
mockTeamDataWorkerClient.findLogsForTask.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);
|
registerTeamHandlers(ipcMain as never);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -224,6 +267,10 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).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_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_GET_MEMBER_STATS)).toBe(true);
|
||||||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true);
|
||||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).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');
|
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 () => {
|
it('returns success false on invalid sendMessage args', async () => {
|
||||||
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
const sendHandler = handlers.get(TEAM_SEND_MESSAGE);
|
||||||
expect(sendHandler).toBeDefined();
|
expect(sendHandler).toBeDefined();
|
||||||
|
|
@ -893,6 +1083,8 @@ describe('ipc teams handlers', () => {
|
||||||
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_MEMBER_LOGS)).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_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_GET_MEMBER_STATS)).toBe(false);
|
||||||
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false);
|
||||||
expect(handlers.has(TEAM_GET_ALL_TASKS)).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);
|
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', () => {
|
describe('addTaskRelationship', () => {
|
||||||
it('calls service on valid input', async () => {
|
it('calls service on valid input', async () => {
|
||||||
const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!;
|
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);
|
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 () => {
|
it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-'));
|
||||||
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
|
const jsonlPath = path.join(tmpDir, 'mixed.jsonl');
|
||||||
|
|
|
||||||
|
|
@ -867,6 +867,34 @@ describe('TeamMemberLogsFinder', () => {
|
||||||
await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false);
|
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 () => {
|
it('findLogFileRefsForTask returns correct refs for a task', async () => {
|
||||||
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-'));
|
tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-'));
|
||||||
setClaudeBasePathOverride(tmpDir);
|
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