agent-ecosystem/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts

613 lines
18 KiB
TypeScript

import {
describeBoardTaskActivityLabel,
formatBoardTaskActivityTaskLabel,
} from '@shared/utils/boardTaskActivityLabels';
import {
describeBoardTaskActivityActorLabel,
describeBoardTaskActivityContextLines,
} from '@shared/utils/boardTaskActivityPresentation';
import { isEnhancedAIChunk } from '@main/types';
import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource';
import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder';
import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector';
import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser';
import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord';
import type {
BoardTaskActivityDetail,
BoardTaskActivityDetailMetadataRow,
BoardTaskActivityDetailResult,
} from '@shared/types';
import type { BoardTaskExactLogBundleCandidate } from '../exact/BoardTaskExactLogTypes';
import type { ContentBlock, EnhancedChunk, ParsedMessage, ToolUseResultData } from '@main/types';
const READ_ONLY_TOOL_NAMES = new Set(['task_get', 'task_get_comment']);
function scopeLabel(record: BoardTaskActivityRecord): string {
switch (record.actorContext.relation) {
case 'same_task':
return 'same task';
case 'other_active_task':
return 'other active task';
case 'idle':
return 'idle';
case 'ambiguous':
return 'ambiguous';
default:
return record.actorContext.relation;
}
}
function formatTaskLabelOrLocator(record: BoardTaskActivityRecord['task']): string {
return formatBoardTaskActivityTaskLabel(record) ?? `#${record.locator.ref}`;
}
function relationshipValue(record: BoardTaskActivityRecord): string | null {
const relationship = record.action?.details?.relationship;
const peerTaskLabel = formatBoardTaskActivityTaskLabel(record.action?.peerTask);
if (relationship && peerTaskLabel) {
return `${relationship} ${peerTaskLabel}`;
}
if (relationship) {
return relationship;
}
if (peerTaskLabel) {
return peerTaskLabel;
}
return null;
}
function buildMetadataRows(record: BoardTaskActivityRecord): BoardTaskActivityDetailMetadataRow[] {
const rows: BoardTaskActivityDetailMetadataRow[] = [
{
label: 'Task',
value: formatTaskLabelOrLocator(record.task),
},
{
label: 'Scope',
value: scopeLabel(record),
},
];
if (record.action?.canonicalToolName) {
rows.push({ label: 'Tool', value: record.action.canonicalToolName });
}
if (record.action?.details?.status) {
rows.push({ label: 'Status', value: record.action.details.status });
}
if ('owner' in (record.action?.details ?? {})) {
rows.push({ label: 'Owner', value: record.action?.details?.owner ?? 'cleared' });
}
if ('clarification' in (record.action?.details ?? {})) {
rows.push({
label: 'Clarification',
value: record.action?.details?.clarification ?? 'cleared',
});
}
if (record.action?.details?.reviewer) {
rows.push({ label: 'Reviewer', value: record.action.details.reviewer });
}
if (record.action?.details?.commentId) {
rows.push({ label: 'Comment', value: record.action.details.commentId });
}
if (record.action?.details?.attachmentId) {
rows.push({ label: 'Attachment ID', value: record.action.details.attachmentId });
}
if (record.action?.details?.filename) {
rows.push({ label: 'File', value: record.action.details.filename });
}
const relationship = relationshipValue(record);
if (relationship) {
rows.push({ label: 'Relationship', value: relationship });
}
const activeTaskLabel = formatBoardTaskActivityTaskLabel(record.actorContext.activeTask);
if (activeTaskLabel) {
rows.push({ label: 'Active task', value: activeTaskLabel });
}
if (record.actorContext.activePhase) {
rows.push({ label: 'Phase', value: record.actorContext.activePhase });
}
return rows;
}
function buildCandidate(record: BoardTaskActivityRecord): BoardTaskExactLogBundleCandidate {
return {
id: `activity:${record.id}`,
timestamp: record.timestamp,
actor: record.actor,
source: {
filePath: record.source.filePath,
messageUuid: record.source.messageUuid,
...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}),
sourceOrder: record.source.sourceOrder,
},
records: [record],
anchor: record.source.toolUseId
? {
kind: 'tool',
filePath: record.source.filePath,
messageUuid: record.source.messageUuid,
toolUseId: record.source.toolUseId,
}
: {
kind: 'message',
filePath: record.source.filePath,
messageUuid: record.source.messageUuid,
},
actionLabel: describeBoardTaskActivityLabel(record),
...(record.action?.category ? { actionCategory: record.action.category } : {}),
...(record.action?.canonicalToolName
? { canonicalToolName: record.action.canonicalToolName }
: {}),
linkKinds: [record.linkKind],
targetRoles: [record.targetRole],
canLoadDetail: false,
};
}
function shouldIncludeLinkedTool(record: BoardTaskActivityRecord): boolean {
const toolName = record.action?.canonicalToolName;
if (!record.source.toolUseId || !toolName) {
return false;
}
return !READ_ONLY_TOOL_NAMES.has(toolName);
}
function looksLikeJsonPayload(value: string): boolean {
const trimmed = value.trim();
return trimmed.startsWith('{') || trimmed.startsWith('[');
}
function parseJsonLikeString(value: string): unknown {
if (!looksLikeJsonPayload(value)) {
return null;
}
try {
return JSON.parse(value);
} 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 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 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 = collectTextBlockText(content.content);
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 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 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 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 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 as ToolUseResultData,
};
}
} 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 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 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 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 sanitizeDetailMessages(
messages: ParsedMessage[],
canonicalToolName: string | undefined,
toolUseId: string | undefined
): ParsedMessage[] {
return pruneEmptyInternalToolResultMessages(
pruneToolAnchoredAssistantOutputMessages(
sanitizeJsonLikeToolResultPayloads(messages, canonicalToolName),
toolUseId
)
);
}
function hasUsefulLinkedToolChunks(chunks: EnhancedChunk[]): boolean {
return chunks.some((chunk) => isEnhancedAIChunk(chunk) && chunk.toolExecutions.length > 0);
}
export class BoardTaskActivityDetailService {
constructor(
private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(),
private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(),
private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(),
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder()
) {}
async getTaskActivityDetail(
teamName: string,
taskId: string,
activityId: string
): Promise<BoardTaskActivityDetailResult> {
const records = await this.recordSource.getTaskRecords(teamName, taskId);
const record = records.find((candidate) => candidate.id === activityId);
if (!record) {
return { status: 'missing' };
}
const detail: BoardTaskActivityDetail = {
entryId: record.id,
summaryLabel: describeBoardTaskActivityLabel(record),
actorLabel: describeBoardTaskActivityActorLabel(record.actor),
timestamp: record.timestamp,
contextLines: describeBoardTaskActivityContextLines(record),
metadataRows: buildMetadataRows(record),
};
if (shouldIncludeLinkedTool(record)) {
const parsedMessagesByFile = await this.strictParser.parseFiles([record.source.filePath]);
const detailCandidate = this.detailSelector.selectDetail({
candidate: buildCandidate(record),
records,
parsedMessagesByFile,
});
if (detailCandidate) {
const filteredMessages = sanitizeDetailMessages(
detailCandidate.filteredMessages,
record.action?.canonicalToolName,
record.source.toolUseId
);
const chunks = this.chunkBuilder.buildBundleChunks(filteredMessages);
if (chunks.length > 0 && hasUsefulLinkedToolChunks(chunks)) {
detail.logDetail = {
id: detailCandidate.id,
chunks,
};
}
}
}
return {
status: 'ok',
detail,
};
}
}