fix(team): preserve task log owners across codex transcripts
This commit is contained in:
parent
2c2f84a00e
commit
1f7f31f3ee
14 changed files with 1224 additions and 67 deletions
108
src/main/services/team/taskLogs/TranscriptSessionActorContext.ts
Normal file
108
src/main/services/team/taskLogs/TranscriptSessionActorContext.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
interface SessionActorContextState {
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
isSidechain?: boolean;
|
||||
agentIdAmbiguous: boolean;
|
||||
agentNameAmbiguous: boolean;
|
||||
isSidechainAmbiguous: boolean;
|
||||
}
|
||||
|
||||
function readNonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function hasBoolean(value: unknown): value is boolean {
|
||||
return typeof value === 'boolean';
|
||||
}
|
||||
|
||||
function cloneWithContext<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
updates: Record<string, unknown>
|
||||
): T {
|
||||
return {
|
||||
...record,
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
|
||||
export class TranscriptSessionActorContextTracker {
|
||||
private readonly contextsBySessionId = new Map<string, SessionActorContextState>();
|
||||
|
||||
remember(record: Record<string, unknown>): void {
|
||||
const sessionId = readNonEmptyString(record.sessionId);
|
||||
if (!sessionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agentId = readNonEmptyString(record.agentId);
|
||||
const agentName = readNonEmptyString(record.agentName);
|
||||
const isSidechain = hasBoolean(record.isSidechain) ? record.isSidechain : undefined;
|
||||
if (!agentId && !agentName && isSidechain === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = this.contextsBySessionId.get(sessionId) ?? {
|
||||
agentIdAmbiguous: false,
|
||||
agentNameAmbiguous: false,
|
||||
isSidechainAmbiguous: false,
|
||||
};
|
||||
|
||||
const next: SessionActorContextState = { ...current };
|
||||
if (agentId) {
|
||||
if (current.agentId && current.agentId !== agentId) {
|
||||
next.agentIdAmbiguous = true;
|
||||
} else {
|
||||
next.agentId = agentId;
|
||||
}
|
||||
}
|
||||
|
||||
if (agentName) {
|
||||
if (current.agentName && current.agentName !== agentName) {
|
||||
next.agentNameAmbiguous = true;
|
||||
} else {
|
||||
next.agentName = agentName;
|
||||
}
|
||||
}
|
||||
|
||||
if (isSidechain !== undefined) {
|
||||
if (current.isSidechain !== undefined && current.isSidechain !== isSidechain) {
|
||||
next.isSidechainAmbiguous = true;
|
||||
} else {
|
||||
next.isSidechain = isSidechain;
|
||||
}
|
||||
}
|
||||
|
||||
this.contextsBySessionId.set(sessionId, next);
|
||||
}
|
||||
|
||||
apply<T extends Record<string, unknown>>(record: T): T {
|
||||
const sessionId = readNonEmptyString(record.sessionId);
|
||||
if (!sessionId) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const context = this.contextsBySessionId.get(sessionId);
|
||||
if (!context) {
|
||||
return record;
|
||||
}
|
||||
|
||||
const updates: Record<string, unknown> = {};
|
||||
if (!readNonEmptyString(record.agentId) && context.agentId && !context.agentIdAmbiguous) {
|
||||
updates.agentId = context.agentId;
|
||||
}
|
||||
|
||||
if (!readNonEmptyString(record.agentName) && context.agentName && !context.agentNameAmbiguous) {
|
||||
updates.agentName = context.agentName;
|
||||
}
|
||||
|
||||
if (
|
||||
!hasBoolean(record.isSidechain) &&
|
||||
context.isSidechain !== undefined &&
|
||||
!context.isSidechainAmbiguous
|
||||
) {
|
||||
updates.isSidechain = context.isSidechain;
|
||||
}
|
||||
|
||||
return Object.keys(updates).length > 0 ? cloneWithContext(record, updates) : record;
|
||||
}
|
||||
}
|
||||
|
|
@ -51,6 +51,11 @@ function normalizeDisplayRef(value: string): string {
|
|||
return value.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function isConventionalLeadName(value: string): boolean {
|
||||
const normalized = normalizeDisplayRef(value);
|
||||
return normalized === 'team-lead' || normalized === 'lead';
|
||||
}
|
||||
|
||||
function looksLikeCanonicalTaskId(value: string): boolean {
|
||||
return CANONICAL_TASK_ID_PATTERN.test(value.trim());
|
||||
}
|
||||
|
|
@ -245,16 +250,17 @@ function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivit
|
|||
typeof message.agentName === 'string' && message.agentName.trim().length > 0
|
||||
? message.agentName.trim()
|
||||
: undefined;
|
||||
const role: BoardTaskActivityActor['role'] = memberName
|
||||
? isConventionalLeadName(memberName)
|
||||
? 'lead'
|
||||
: 'member'
|
||||
: message.isSidechain
|
||||
? 'member'
|
||||
: 'unknown';
|
||||
|
||||
return {
|
||||
...(memberName ? { memberName } : {}),
|
||||
role: memberName
|
||||
? message.isSidechain
|
||||
? 'member'
|
||||
: 'lead'
|
||||
: message.isSidechain
|
||||
? 'member'
|
||||
: 'unknown',
|
||||
role,
|
||||
sessionId: message.sessionId,
|
||||
...(message.agentId ? { agentId: message.agentId } : {}),
|
||||
isSidechain: message.isSidechain,
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
type ParsedBoardTaskLink,
|
||||
type ParsedBoardTaskToolAction,
|
||||
} from '../contract/BoardTaskTranscriptContract';
|
||||
import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext';
|
||||
|
||||
import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache';
|
||||
|
||||
|
|
@ -56,6 +57,12 @@ function asRecord(value: unknown): Record<string, unknown> | null {
|
|||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function lineMayContainTaskActivityOrActorContext(line: string): boolean {
|
||||
return (
|
||||
line.includes('"boardTaskLinks"') || line.includes('"agentName"') || line.includes('"agentId"')
|
||||
);
|
||||
}
|
||||
|
||||
export class BoardTaskActivityTranscriptReader {
|
||||
private readonly cache = new BoardTaskActivityParseCache<RawTaskActivityMessage[]>();
|
||||
|
||||
|
|
@ -112,6 +119,7 @@ export class BoardTaskActivityTranscriptReader {
|
|||
|
||||
private async parseFile(filePath: string): Promise<RawTaskActivityMessage[]> {
|
||||
const results: RawTaskActivityMessage[] = [];
|
||||
const actorContextTracker = new TranscriptSessionActorContextTracker();
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
|
|
@ -123,7 +131,7 @@ export class BoardTaskActivityTranscriptReader {
|
|||
for await (const line of rl) {
|
||||
if (!line.trim()) continue;
|
||||
lineCount += 1;
|
||||
if (!line.includes('"boardTaskLinks"')) {
|
||||
if (!lineMayContainTaskActivityOrActorContext(line)) {
|
||||
if (lineCount % 500 === 0) {
|
||||
await yieldToEventLoop();
|
||||
}
|
||||
|
|
@ -134,6 +142,11 @@ export class BoardTaskActivityTranscriptReader {
|
|||
const parsed = JSON.parse(line) as unknown;
|
||||
const record = asRecord(parsed);
|
||||
if (!record) continue;
|
||||
actorContextTracker.remember(record);
|
||||
|
||||
if (!line.includes('"boardTaskLinks"')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const uuid = typeof record.uuid === 'string' ? record.uuid : '';
|
||||
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : '';
|
||||
|
|
@ -142,6 +155,7 @@ export class BoardTaskActivityTranscriptReader {
|
|||
|
||||
const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks);
|
||||
if (boardTaskLinks.length === 0) continue;
|
||||
const contextRecord = actorContextTracker.apply(record);
|
||||
|
||||
sourceOrder += 1;
|
||||
results.push({
|
||||
|
|
@ -149,9 +163,10 @@ export class BoardTaskActivityTranscriptReader {
|
|||
uuid,
|
||||
timestamp,
|
||||
sessionId,
|
||||
agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
|
||||
agentName: typeof record.agentName === 'string' ? record.agentName : undefined,
|
||||
isSidechain: record.isSidechain === true,
|
||||
agentId: typeof contextRecord.agentId === 'string' ? contextRecord.agentId : undefined,
|
||||
agentName:
|
||||
typeof contextRecord.agentName === 'string' ? contextRecord.agentName : undefined,
|
||||
isSidechain: contextRecord.isSidechain === true,
|
||||
boardTaskLinks,
|
||||
boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions),
|
||||
sourceOrder,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ function isEmptyToolPayload(value: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function collectEmptyPayloadExamples(
|
||||
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
|
||||
): BoardTaskLogDiagnosticExample[] {
|
||||
|
|
@ -194,7 +200,7 @@ function collectEmptyPayloadExamples(
|
|||
});
|
||||
}
|
||||
|
||||
const toolUseResult = message.toolUseResult;
|
||||
const toolUseResult = asObjectRecord(message.toolUseResult);
|
||||
if (!toolUseResult) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,11 +16,150 @@ interface TentativeFilteredMessage {
|
|||
matchedToolUseId?: string;
|
||||
}
|
||||
|
||||
function isToolAnchoredOutputMessage(
|
||||
message: ParsedMessage,
|
||||
toolUseId: string | undefined
|
||||
): boolean {
|
||||
return Boolean(toolUseId && message.sourceToolUseID === toolUseId);
|
||||
interface ToolAnchorScope {
|
||||
toolUseId?: string;
|
||||
assistantUuids: Set<string>;
|
||||
outputMessageUuids: Set<string>;
|
||||
}
|
||||
|
||||
function messageHasToolUse(message: ParsedMessage, toolUseId: string | undefined): boolean {
|
||||
if (!toolUseId || message.type !== 'assistant' || typeof message.content === 'string') {
|
||||
return false;
|
||||
}
|
||||
return message.content.some((block) => block.type === 'tool_use' && block.id === toolUseId);
|
||||
}
|
||||
|
||||
function messageHasToolResult(message: ParsedMessage, toolUseId: string | undefined): boolean {
|
||||
if (!toolUseId || typeof message.content === 'string') {
|
||||
return false;
|
||||
}
|
||||
return message.content.some(
|
||||
(block) => block.type === 'tool_result' && block.tool_use_id === toolUseId
|
||||
);
|
||||
}
|
||||
|
||||
function buildToolAnchorScope(args: {
|
||||
candidate: BoardTaskExactLogBundleCandidate;
|
||||
parsedMessages: ParsedMessage[];
|
||||
explicitMessageIds: Set<string>;
|
||||
}): ToolAnchorScope {
|
||||
const toolUseId =
|
||||
args.candidate.anchor.kind === 'tool' ? args.candidate.anchor.toolUseId : undefined;
|
||||
const assistantUuids = new Set<string>();
|
||||
const outputMessageUuids = new Set<string>();
|
||||
if (!toolUseId) {
|
||||
return { assistantUuids, outputMessageUuids };
|
||||
}
|
||||
|
||||
const messagesByUuid = new Map(args.parsedMessages.map((message) => [message.uuid, message]));
|
||||
const messageIndexByUuid = new Map(
|
||||
args.parsedMessages.map((message, index) => [message.uuid, index])
|
||||
);
|
||||
|
||||
const addMatchingAssistant = (uuid: string | null | undefined): void => {
|
||||
if (!uuid) {
|
||||
return;
|
||||
}
|
||||
const message = messagesByUuid.get(uuid);
|
||||
if (message && messageHasToolUse(message, toolUseId)) {
|
||||
assistantUuids.add(message.uuid);
|
||||
}
|
||||
};
|
||||
|
||||
const addNearestPreviousMatchingAssistant = (message: ParsedMessage): void => {
|
||||
const startIndex = messageIndexByUuid.get(message.uuid);
|
||||
if (startIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (let index = startIndex - 1; index >= 0; index -= 1) {
|
||||
const candidate = args.parsedMessages[index];
|
||||
if (!candidate) {
|
||||
continue;
|
||||
}
|
||||
if (candidate.type !== 'assistant') {
|
||||
continue;
|
||||
}
|
||||
if (messageHasToolUse(candidate, toolUseId)) {
|
||||
assistantUuids.add(candidate.uuid);
|
||||
}
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
addMatchingAssistant(args.candidate.anchor.messageUuid);
|
||||
for (const explicitMessageId of args.explicitMessageIds) {
|
||||
const message = messagesByUuid.get(explicitMessageId);
|
||||
if (!message) {
|
||||
continue;
|
||||
}
|
||||
addMatchingAssistant(message.uuid);
|
||||
addMatchingAssistant(message.sourceToolAssistantUUID);
|
||||
addMatchingAssistant(message.parentUuid);
|
||||
if (message.type === 'user' && messageHasToolResult(message, toolUseId)) {
|
||||
addNearestPreviousMatchingAssistant(message);
|
||||
}
|
||||
}
|
||||
|
||||
let previousAssistantUuid: string | undefined;
|
||||
for (const message of args.parsedMessages) {
|
||||
const referencesTool =
|
||||
message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId);
|
||||
if (
|
||||
referencesTool &&
|
||||
((message.sourceToolAssistantUUID !== undefined &&
|
||||
assistantUuids.has(message.sourceToolAssistantUUID)) ||
|
||||
(message.parentUuid !== null &&
|
||||
message.parentUuid !== undefined &&
|
||||
assistantUuids.has(message.parentUuid)) ||
|
||||
(message.sourceToolAssistantUUID === undefined &&
|
||||
(message.parentUuid === null || message.parentUuid === undefined) &&
|
||||
previousAssistantUuid !== undefined &&
|
||||
assistantUuids.has(previousAssistantUuid)))
|
||||
) {
|
||||
outputMessageUuids.add(message.uuid);
|
||||
}
|
||||
|
||||
if (message.type === 'assistant') {
|
||||
previousAssistantUuid = message.uuid;
|
||||
}
|
||||
}
|
||||
|
||||
return { toolUseId, assistantUuids, outputMessageUuids };
|
||||
}
|
||||
|
||||
function isToolLinkedMessage(message: ParsedMessage, scope: ToolAnchorScope): boolean {
|
||||
const { toolUseId } = scope;
|
||||
if (!toolUseId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasScopedAssistant = scope.assistantUuids.size > 0;
|
||||
if (scope.outputMessageUuids.has(message.uuid)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (message.type === 'assistant' && messageHasToolUse(message, toolUseId)) {
|
||||
return !hasScopedAssistant || scope.assistantUuids.has(message.uuid);
|
||||
}
|
||||
|
||||
const referencesTool =
|
||||
message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId);
|
||||
if (!referencesTool) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasScopedAssistant) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return (
|
||||
(message.sourceToolAssistantUUID !== undefined &&
|
||||
scope.assistantUuids.has(message.sourceToolAssistantUUID)) ||
|
||||
(message.parentUuid !== null &&
|
||||
message.parentUuid !== undefined &&
|
||||
scope.assistantUuids.has(message.parentUuid))
|
||||
);
|
||||
}
|
||||
|
||||
function noteExactDiagnostic(
|
||||
|
|
@ -120,16 +259,18 @@ function filterMessageForCandidate(args: {
|
|||
message: ParsedMessage;
|
||||
candidate: BoardTaskExactLogBundleCandidate;
|
||||
explicitMessageIds: Set<string>;
|
||||
toolAnchorScope: ToolAnchorScope;
|
||||
}): TentativeFilteredMessage | null {
|
||||
const { message, candidate, explicitMessageIds } = args;
|
||||
const { message, candidate, explicitMessageIds, toolAnchorScope } = args;
|
||||
const explicitMessageLinked = explicitMessageIds.has(message.uuid);
|
||||
const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined;
|
||||
const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId);
|
||||
const toolLinked = isToolLinkedMessage(message, toolAnchorScope);
|
||||
|
||||
if (!explicitMessageLinked && !toolLinked) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (typeof message.content === 'string') {
|
||||
if (!explicitMessageLinked && !anchoredOutputLinked) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
original: message,
|
||||
filteredContent: message.content,
|
||||
|
|
@ -142,7 +283,7 @@ function filterMessageForCandidate(args: {
|
|||
filteredBlocks = filterAssistantContent(
|
||||
message.content,
|
||||
toolUseId,
|
||||
explicitMessageLinked || anchoredOutputLinked
|
||||
explicitMessageLinked || toolLinked
|
||||
);
|
||||
} else if (message.type === 'user') {
|
||||
filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked);
|
||||
|
|
@ -309,6 +450,11 @@ export class BoardTaskExactLogDetailSelector {
|
|||
}
|
||||
|
||||
const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid));
|
||||
const toolAnchorScope = buildToolAnchorScope({
|
||||
candidate,
|
||||
parsedMessages,
|
||||
explicitMessageIds,
|
||||
});
|
||||
const tentative: TentativeFilteredMessage[] = [];
|
||||
|
||||
for (const message of parsedMessages) {
|
||||
|
|
@ -316,6 +462,7 @@ export class BoardTaskExactLogDetailSelector {
|
|||
message,
|
||||
candidate,
|
||||
explicitMessageIds,
|
||||
toolAnchorScope,
|
||||
});
|
||||
if (filtered) {
|
||||
tentative.push(filtered);
|
||||
|
|
|
|||
|
|
@ -5,6 +5,8 @@ import { createReadStream } from 'fs';
|
|||
import * as fs from 'fs/promises';
|
||||
import * as readline from 'readline';
|
||||
|
||||
import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext';
|
||||
|
||||
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
|
||||
|
||||
import type { ParsedMessage } from '@main/types';
|
||||
|
|
@ -106,6 +108,7 @@ export class BoardTaskExactLogStrictParser {
|
|||
|
||||
private async readStrictFile(filePath: string): Promise<ParsedMessage[]> {
|
||||
const results: ParsedMessage[] = [];
|
||||
const actorContextTracker = new TranscriptSessionActorContextTracker();
|
||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||
const rl = readline.createInterface({
|
||||
input: stream,
|
||||
|
|
@ -124,7 +127,9 @@ export class BoardTaskExactLogStrictParser {
|
|||
continue;
|
||||
}
|
||||
|
||||
const parsed = parseJsonlEntry(record as unknown as ChatHistoryEntry);
|
||||
actorContextTracker.remember(record);
|
||||
const contextRecord = actorContextTracker.apply(record);
|
||||
const parsed = parseJsonlEntry(contextRecord as unknown as ChatHistoryEntry);
|
||||
if (parsed) {
|
||||
results.push(parsed);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
|||
|
||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
|
||||
import type { TaskLogRuntimeStreamSource } from './TaskLogRuntimeStreamSource';
|
||||
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
||||
import type {
|
||||
BoardTaskActivityCategory,
|
||||
|
|
@ -59,7 +60,7 @@ interface TimeWindow {
|
|||
interface StreamLayout {
|
||||
participants: BoardTaskLogParticipant[];
|
||||
visibleSlices: StreamSlice[];
|
||||
shouldMergeOpenCodeRuntimeFallback?: boolean;
|
||||
shouldMergeRuntimeFallback?: boolean;
|
||||
}
|
||||
|
||||
const logger = createLogger('Service:BoardTaskLogStreamService');
|
||||
|
|
@ -90,6 +91,7 @@ const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([
|
|||
'task_set_owner',
|
||||
'task_unlink',
|
||||
]);
|
||||
const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']);
|
||||
const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']);
|
||||
|
||||
function emptyResponse(): BoardTaskLogStreamResponse {
|
||||
|
|
@ -269,6 +271,24 @@ function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActi
|
|||
}
|
||||
}
|
||||
|
||||
function historicalBoardToolReferencesTask(args: {
|
||||
canonicalToolName: string;
|
||||
input: Record<string, unknown>;
|
||||
resultPayload: unknown;
|
||||
taskRefs: Set<string>;
|
||||
}): boolean {
|
||||
const { canonicalToolName, input, resultPayload, taskRefs } = args;
|
||||
if (valueReferencesTask(input, taskRefs)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return valueReferencesTask(resultPayload, taskRefs);
|
||||
}
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
@ -519,6 +539,74 @@ function parseJsonLikeString(value: string): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
function formatTaskStatusPayload(payload: Record<string, unknown>): string | null {
|
||||
const displayId =
|
||||
typeof payload.displayId === 'string' && payload.displayId.trim().length > 0
|
||||
? payload.displayId.trim()
|
||||
: typeof payload.id === 'string' && payload.id.trim().length > 0
|
||||
? payload.id.trim()
|
||||
: null;
|
||||
const status =
|
||||
typeof payload.status === 'string' && payload.status.trim().length > 0
|
||||
? payload.status.trim()
|
||||
: null;
|
||||
if (!displayId || !status) {
|
||||
return null;
|
||||
}
|
||||
return `Task ${displayId} ${status}`;
|
||||
}
|
||||
|
||||
function formatMessageSendPayload(payload: Record<string, unknown>): string | null {
|
||||
const routing = payload.routing as Record<string, unknown> | undefined;
|
||||
const messageRecord =
|
||||
typeof payload.message === 'object' && payload.message !== null
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: undefined;
|
||||
const deliveryMessage =
|
||||
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||
? payload.message.trim()
|
||||
: null;
|
||||
const summary =
|
||||
typeof messageRecord?.summary === 'string' && messageRecord.summary.trim().length > 0
|
||||
? messageRecord.summary.trim()
|
||||
: typeof routing?.summary === 'string' && routing.summary.trim().length > 0
|
||||
? routing.summary.trim()
|
||||
: null;
|
||||
const target =
|
||||
typeof messageRecord?.to === 'string' && messageRecord.to.trim().length > 0
|
||||
? messageRecord.to.trim()
|
||||
: typeof routing?.target === 'string' && routing.target.trim().length > 0
|
||||
? routing.target.trim()
|
||||
: null;
|
||||
const messageText =
|
||||
typeof messageRecord?.text === 'string' && messageRecord.text.trim().length > 0
|
||||
? messageRecord.text.trim()
|
||||
: null;
|
||||
|
||||
if (deliveryMessage && summary) {
|
||||
return `${deliveryMessage} - ${summary}`;
|
||||
}
|
||||
if (summary && target) {
|
||||
return `Message sent to ${target} - ${summary}`;
|
||||
}
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
if (deliveryMessage) {
|
||||
return deliveryMessage;
|
||||
}
|
||||
if (messageText && target) {
|
||||
return `Message sent to ${target} - ${messageText}`;
|
||||
}
|
||||
if (messageText) {
|
||||
return messageText;
|
||||
}
|
||||
if (target) {
|
||||
return `Message sent to ${target}`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractBoardToolOutputText(
|
||||
toolName: string | undefined,
|
||||
parsedPayload: unknown
|
||||
|
|
@ -527,7 +615,7 @@ function extractBoardToolOutputText(
|
|||
return null;
|
||||
}
|
||||
|
||||
const normalizedToolName = toolName.trim().toLowerCase();
|
||||
const normalizedToolName = canonicalizeBoardToolName(toolName) ?? toolName.trim().toLowerCase();
|
||||
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
|
||||
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
||||
const comment = payload.comment as Record<string, unknown> | undefined;
|
||||
|
|
@ -536,36 +624,20 @@ function extractBoardToolOutputText(
|
|||
}
|
||||
}
|
||||
|
||||
if (normalizedToolName === 'sendmessage') {
|
||||
const routing = payload.routing as Record<string, unknown> | undefined;
|
||||
const deliveryMessage =
|
||||
typeof payload.message === 'string' && payload.message.trim().length > 0
|
||||
? payload.message.trim()
|
||||
: null;
|
||||
const summary =
|
||||
typeof routing?.summary === 'string' && routing.summary.trim().length > 0
|
||||
? routing.summary.trim()
|
||||
: null;
|
||||
const target =
|
||||
typeof routing?.target === 'string' && routing.target.trim().length > 0
|
||||
? routing.target.trim()
|
||||
: null;
|
||||
if (normalizedToolName === 'task_complete') {
|
||||
return formatTaskStatusPayload(payload) ?? 'Task completed';
|
||||
}
|
||||
|
||||
if (deliveryMessage && summary) {
|
||||
return `${deliveryMessage} - ${summary}`;
|
||||
}
|
||||
if (summary && target) {
|
||||
return `Message sent to ${target} - ${summary}`;
|
||||
}
|
||||
if (summary) {
|
||||
return summary;
|
||||
}
|
||||
if (deliveryMessage) {
|
||||
return deliveryMessage;
|
||||
}
|
||||
if (target) {
|
||||
return `Message sent to ${target}`;
|
||||
}
|
||||
if (normalizedToolName === 'sendmessage' || normalizedToolName === 'message_send') {
|
||||
return formatMessageSendPayload(payload);
|
||||
}
|
||||
|
||||
if (payload.message) {
|
||||
return formatMessageSendPayload(payload);
|
||||
}
|
||||
|
||||
if (payload.status) {
|
||||
return formatTaskStatusPayload(payload);
|
||||
}
|
||||
|
||||
return null;
|
||||
|
|
@ -1303,7 +1375,10 @@ function collectExplicitToolUseIds(
|
|||
|
||||
function collectAllowedMemberNames(
|
||||
task: TeamTask,
|
||||
records: { actor: { memberName?: string } }[]
|
||||
records: {
|
||||
actor: { memberName?: string };
|
||||
action?: { category?: BoardTaskActivityCategory; canonicalToolName?: string };
|
||||
}[]
|
||||
): Set<string> {
|
||||
const allowedNames = new Set<string>();
|
||||
|
||||
|
|
@ -1312,6 +1387,14 @@ function collectAllowedMemberNames(
|
|||
}
|
||||
|
||||
for (const record of records) {
|
||||
const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName);
|
||||
if (
|
||||
record.action?.category === 'read' ||
|
||||
(canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) {
|
||||
allowedNames.add(normalizeMemberName(record.actor.memberName));
|
||||
}
|
||||
|
|
@ -1523,7 +1606,7 @@ export class BoardTaskLogStreamService {
|
|||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
||||
private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource(),
|
||||
private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(),
|
||||
private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||
private readonly configReader: TeamConfigReader = new TeamConfigReader()
|
||||
) {}
|
||||
|
|
@ -1792,8 +1875,12 @@ export class BoardTaskLogStreamService {
|
|||
|
||||
const resultPayload = resolveToolResultPayload(message, toolResult);
|
||||
if (
|
||||
!valueReferencesTask(toolCall.input, taskRefs) &&
|
||||
!valueReferencesTask(resultPayload, taskRefs)
|
||||
!historicalBoardToolReferencesTask({
|
||||
canonicalToolName: toolCall.canonicalToolName,
|
||||
input: toolCall.input,
|
||||
resultPayload,
|
||||
taskRefs,
|
||||
})
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
|
|
@ -1983,15 +2070,11 @@ export class BoardTaskLogStreamService {
|
|||
return {
|
||||
participants: buildOrderedParticipants(visibleSlices),
|
||||
visibleSlices,
|
||||
shouldMergeOpenCodeRuntimeFallback: await this.shouldMergeOpenCodeRuntimeFallback(
|
||||
teamName,
|
||||
taskId,
|
||||
records
|
||||
),
|
||||
shouldMergeRuntimeFallback: await this.shouldMergeRuntimeFallback(teamName, taskId, records),
|
||||
};
|
||||
}
|
||||
|
||||
private async shouldMergeOpenCodeRuntimeFallback(
|
||||
private async shouldMergeRuntimeFallback(
|
||||
teamName: string,
|
||||
taskId: string,
|
||||
records: BoardTaskActivityRecord[]
|
||||
|
|
@ -2032,7 +2115,7 @@ export class BoardTaskLogStreamService {
|
|||
const elapsedMs = Date.now() - startedAt;
|
||||
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
||||
logger.warn(
|
||||
`Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean(
|
||||
`Slow task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean(
|
||||
fallback
|
||||
)} elapsedMs=${elapsedMs}`
|
||||
);
|
||||
|
|
@ -2121,7 +2204,7 @@ export class BoardTaskLogStreamService {
|
|||
source: 'transcript',
|
||||
};
|
||||
|
||||
if (!layout.shouldMergeOpenCodeRuntimeFallback) {
|
||||
if (!layout.shouldMergeRuntimeFallback) {
|
||||
return primaryResponse;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import type { BoardTaskLogStreamResponse } from '@shared/types';
|
||||
|
||||
export interface TaskLogRuntimeStreamSource {
|
||||
getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse | null>;
|
||||
}
|
||||
|
|
@ -300,6 +300,51 @@ describe('BoardTaskActivityEntryBuilder', () => {
|
|||
expect(entries[0]?.actor.role).toBe('unknown');
|
||||
});
|
||||
|
||||
it('keeps named main-session teammates as members instead of forcing lead role', () => {
|
||||
const taskA = makeTask({
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
displayId: 'abcd1234',
|
||||
subject: 'Task A',
|
||||
status: 'in_progress',
|
||||
});
|
||||
|
||||
const messages: RawTaskActivityMessage[] = [
|
||||
{
|
||||
filePath: '/tmp/main-member.jsonl',
|
||||
uuid: 'msg-main-member',
|
||||
timestamp: '2026-04-12T10:00:00.000Z',
|
||||
sessionId: 'session-1',
|
||||
agentName: 'tom',
|
||||
isSidechain: false,
|
||||
sourceOrder: 1,
|
||||
boardTaskLinks: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id },
|
||||
targetRole: 'subject',
|
||||
linkKind: 'board_action',
|
||||
actorContext: { relation: 'same_task' },
|
||||
},
|
||||
],
|
||||
boardTaskToolActions: [],
|
||||
},
|
||||
];
|
||||
|
||||
const entries = new BoardTaskActivityEntryBuilder().buildForTask({
|
||||
teamName: 'demo',
|
||||
targetTask: taskA,
|
||||
tasks: [taskA],
|
||||
messages,
|
||||
});
|
||||
|
||||
expect(entries).toHaveLength(1);
|
||||
expect(entries[0]?.actor).toMatchObject({
|
||||
memberName: 'tom',
|
||||
role: 'member',
|
||||
isSidechain: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('never joins action payloads onto execution rows', () => {
|
||||
const taskA = makeTask({
|
||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
||||
|
|
|
|||
|
|
@ -94,4 +94,57 @@ describe('BoardTaskActivityTranscriptReader', () => {
|
|||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('inherits stable session actor context for task-linked Codex projection rows', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-transcript-reader-actor-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const filePath = path.join(tempDir, 'codex-session.jsonl');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
uuid: 'session-context',
|
||||
sessionId: 'session-codex',
|
||||
timestamp: '2026-04-20T12:00:00.000Z',
|
||||
agentName: 'tom',
|
||||
isSidechain: false,
|
||||
message: { role: 'assistant', content: 'Starting task' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
uuid: 'linked-without-agent-name',
|
||||
sessionId: 'session-codex',
|
||||
timestamp: '2026-04-20T12:01:00.000Z',
|
||||
boardTaskLinks: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
task: { ref: '12345678', refKind: 'display', canonicalId: 'task-a' },
|
||||
targetRole: 'subject',
|
||||
linkKind: 'board_action',
|
||||
actorContext: { relation: 'same_task' },
|
||||
toolUseId: 'toolu_task_comment',
|
||||
},
|
||||
],
|
||||
boardTaskToolActions: [
|
||||
{
|
||||
schemaVersion: 1,
|
||||
toolUseId: 'toolu_task_comment',
|
||||
canonicalToolName: 'task_add_comment',
|
||||
},
|
||||
],
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]);
|
||||
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0]).toMatchObject({
|
||||
uuid: 'linked-without-agent-name',
|
||||
sessionId: 'session-codex',
|
||||
agentName: 'tom',
|
||||
isSidechain: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,6 +166,134 @@ describe('BoardTaskExactLogDetailSelector', () => {
|
|||
expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1');
|
||||
});
|
||||
|
||||
it('scopes repeated tool use ids to the explicit tool-result parent assistant', () => {
|
||||
const record = {
|
||||
...makeRecord(),
|
||||
id: 'record-reused-tool-id',
|
||||
action: {
|
||||
canonicalToolName: 'task_add_comment',
|
||||
toolUseId: 'item_24',
|
||||
category: 'comment' as const,
|
||||
},
|
||||
source: {
|
||||
filePath: '/tmp/task.jsonl',
|
||||
messageUuid: 'user-target',
|
||||
toolUseId: 'item_24',
|
||||
sourceOrder: 1,
|
||||
},
|
||||
} satisfies BoardTaskActivityRecord;
|
||||
const candidate: BoardTaskExactLogBundleCandidate = {
|
||||
id: 'tool:/tmp/task.jsonl:item_24',
|
||||
timestamp: '2026-04-12T16:00:00.000Z',
|
||||
actor: record.actor,
|
||||
source: {
|
||||
filePath: '/tmp/task.jsonl',
|
||||
messageUuid: 'user-target',
|
||||
toolUseId: 'item_24',
|
||||
sourceOrder: 1,
|
||||
},
|
||||
records: [record],
|
||||
anchor: {
|
||||
kind: 'tool',
|
||||
filePath: '/tmp/task.jsonl',
|
||||
messageUuid: 'user-target',
|
||||
toolUseId: 'item_24',
|
||||
},
|
||||
actionLabel: 'Added a comment',
|
||||
actionCategory: 'comment',
|
||||
canonicalToolName: 'task_add_comment',
|
||||
linkKinds: ['board_action'],
|
||||
targetRoles: ['subject'],
|
||||
canLoadDetail: true,
|
||||
sourceGeneration: 'gen-reused-tool-id',
|
||||
};
|
||||
const parsedMessagesByFile = new Map<string, ParsedMessage[]>([
|
||||
[
|
||||
'/tmp/task.jsonl',
|
||||
[
|
||||
{
|
||||
uuid: 'assistant-target',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-12T16:00:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'item_24', name: 'task_add_comment', input: { taskId: 'task-a' } } as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'user-target',
|
||||
parentUuid: 'assistant-target',
|
||||
type: 'user',
|
||||
timestamp: new Date('2026-04-12T16:00:01.000Z'),
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'item_24', content: 'target result' } as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
sourceToolUseID: 'item_24',
|
||||
sourceToolAssistantUUID: 'assistant-target',
|
||||
toolUseResult: { toolUseId: 'item_24', content: 'target result' },
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'assistant-other',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-12T16:10:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{ type: 'tool_use', id: 'item_24', name: 'task_complete', input: { taskId: 'other-task' } } as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'user-other',
|
||||
parentUuid: 'assistant-other',
|
||||
type: 'user',
|
||||
timestamp: new Date('2026-04-12T16:10:01.000Z'),
|
||||
role: 'user',
|
||||
content: [
|
||||
{ type: 'tool_result', tool_use_id: 'item_24', content: 'other result' } as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
sourceToolUseID: 'item_24',
|
||||
sourceToolAssistantUUID: 'assistant-other',
|
||||
toolUseResult: { toolUseId: 'item_24', content: 'other result' },
|
||||
isSidechain: true,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
]);
|
||||
|
||||
const detail = new BoardTaskExactLogDetailSelector().selectDetail({
|
||||
candidate,
|
||||
records: [record],
|
||||
parsedMessagesByFile,
|
||||
});
|
||||
|
||||
expect(detail).not.toBeNull();
|
||||
expect(detail?.filteredMessages.map((message) => message.uuid)).toEqual([
|
||||
'assistant-target',
|
||||
'user-target',
|
||||
]);
|
||||
});
|
||||
|
||||
it('drops stale derived tool metadata when a message-linked row survives filtering', () => {
|
||||
const record = {
|
||||
...makeRecord(),
|
||||
|
|
|
|||
|
|
@ -142,4 +142,63 @@ describe('BoardTaskExactLogStrictParser', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('inherits stable session actor context for Codex-native rows without agentName', async () => {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-actor-'));
|
||||
tempDirs.push(tempDir);
|
||||
|
||||
const filePath = path.join(tempDir, 'native-session.jsonl');
|
||||
await fs.writeFile(
|
||||
filePath,
|
||||
[
|
||||
JSON.stringify({
|
||||
parentUuid: null,
|
||||
isSidechain: false,
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-codex',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
agentName: 'tom',
|
||||
type: 'system',
|
||||
uuid: 'session-context',
|
||||
timestamp: '2026-04-19T10:00:00.000Z',
|
||||
subtype: 'init',
|
||||
level: 'info',
|
||||
isMeta: false,
|
||||
content: 'started',
|
||||
}),
|
||||
JSON.stringify({
|
||||
parentUuid: 'session-context',
|
||||
userType: 'external',
|
||||
cwd: '/tmp/project',
|
||||
sessionId: 'session-codex',
|
||||
version: '1.0.0',
|
||||
gitBranch: 'main',
|
||||
type: 'assistant',
|
||||
uuid: 'assistant-without-agent',
|
||||
timestamp: '2026-04-19T10:00:01.000Z',
|
||||
requestId: 'req-1',
|
||||
message: {
|
||||
role: 'assistant',
|
||||
id: 'msg-1',
|
||||
type: 'message',
|
||||
model: 'codex',
|
||||
content: [{ type: 'text', text: 'working' }],
|
||||
stop_reason: null,
|
||||
stop_sequence: null,
|
||||
usage: { input_tokens: 1, output_tokens: 1 },
|
||||
},
|
||||
}),
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
|
||||
const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]);
|
||||
|
||||
expect(parsed.get(filePath)?.map((message) => [message.uuid, message.agentName])).toEqual([
|
||||
['session-context', 'tom'],
|
||||
['assistant-without-agent', 'tom'],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -354,4 +354,115 @@ describe('BoardTaskLogDiagnosticsService', () => {
|
|||
expect(report.stream.visibleToolNames).toContain('mcp__agent-teams__task_complete');
|
||||
expect(report.diagnosis.join(' ')).not.toContain('Only board MCP actions are explicit');
|
||||
});
|
||||
|
||||
it('ignores non-record toolUseResult values when checking empty stream payloads', async () => {
|
||||
const task = createTask();
|
||||
const taskReader = {
|
||||
getTasks: async () => [task],
|
||||
getDeletedTasks: async () => [] as TeamTask[],
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
listTranscriptFiles: async () => [] as string[],
|
||||
};
|
||||
const recordSource = {
|
||||
getTaskRecords: async () => [],
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: async () => new Map(),
|
||||
};
|
||||
const streamService = {
|
||||
getTaskLogStream: async () => ({
|
||||
participants: [],
|
||||
defaultFilter: 'all' as const,
|
||||
segments: [
|
||||
{
|
||||
id: 'segment-1',
|
||||
participantKey: 'member:tom',
|
||||
actor: {
|
||||
memberName: 'tom',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-tom',
|
||||
isSidechain: false,
|
||||
},
|
||||
startTimestamp: '2026-04-12T15:36:00.000Z',
|
||||
endTimestamp: '2026-04-12T15:36:00.000Z',
|
||||
chunks: [
|
||||
{
|
||||
id: 'chunk-1',
|
||||
rawMessages: [
|
||||
{
|
||||
uuid: 'assistant-1',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-12T15:36:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-1',
|
||||
name: 'mcp__agent-teams__task_add_comment',
|
||||
input: {},
|
||||
},
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-1',
|
||||
name: 'mcp__agent-teams__task_add_comment',
|
||||
input: {},
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'user-1',
|
||||
parentUuid: 'assistant-1',
|
||||
type: 'user',
|
||||
timestamp: new Date('2026-04-12T15:36:01.000Z'),
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-1',
|
||||
content: 'validation failed',
|
||||
is_error: true,
|
||||
},
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'tool-1',
|
||||
content: 'validation failed',
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
sourceToolUseID: 'tool-1',
|
||||
toolUseResult: new Error('validation failed'),
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const diagnosticsService = new BoardTaskLogDiagnosticsService(
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
recordSource as never,
|
||||
strictParser as never,
|
||||
streamService as never,
|
||||
);
|
||||
|
||||
const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID);
|
||||
|
||||
expect(report.stream.emptyPayloadExamples).toEqual([]);
|
||||
expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -872,6 +872,259 @@ describe('BoardTaskLogStreamService', () => {
|
|||
expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']);
|
||||
});
|
||||
|
||||
it('does not use read-only task readers as inferred execution participants', async () => {
|
||||
const alice = {
|
||||
memberName: 'alice',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-alice',
|
||||
isSidechain: false,
|
||||
};
|
||||
const readRecord = {
|
||||
...makeRecord('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'),
|
||||
action: {
|
||||
canonicalToolName: 'task_get',
|
||||
toolUseId: 'tool-read',
|
||||
category: 'read' as const,
|
||||
},
|
||||
};
|
||||
const readCandidate: BoardTaskExactLogBundleCandidate = {
|
||||
...makeCandidate('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'),
|
||||
records: [readRecord],
|
||||
actionCategory: 'read',
|
||||
canonicalToolName: 'task_get',
|
||||
};
|
||||
const aliceRuntimeMessage: ParsedMessage = {
|
||||
uuid: 'alice-bash',
|
||||
parentUuid: null,
|
||||
type: 'assistant',
|
||||
timestamp: new Date('2026-04-12T16:02:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-bash',
|
||||
name: 'Bash',
|
||||
input: { command: 'git diff' },
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-bash',
|
||||
name: 'Bash',
|
||||
input: { command: 'git diff' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
sessionId: 'session-alice',
|
||||
agentName: 'alice',
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
};
|
||||
|
||||
const recordSource = {
|
||||
getTaskRecords: vi.fn(async () => [readRecord]),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => [readCandidate]),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async (filePaths: string[]) =>
|
||||
new Map(
|
||||
filePaths.map((filePath) => [
|
||||
filePath,
|
||||
filePath === '/tmp/alice.jsonl' ? [aliceRuntimeMessage] : [],
|
||||
])
|
||||
)
|
||||
),
|
||||
};
|
||||
const detailSelector = {
|
||||
selectDetail: vi.fn(() => ({
|
||||
id: 'alice-read',
|
||||
timestamp: '2026-04-12T16:00:00.000Z',
|
||||
actor: alice,
|
||||
source: readCandidate.source,
|
||||
records: [readRecord],
|
||||
filteredMessages: [makeMessage('alice-read-detail', '2026-04-12T16:00:00.000Z', 'read')],
|
||||
})),
|
||||
};
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
owner: 'tom',
|
||||
status: 'in_progress',
|
||||
createdAt: '2026-04-12T15:59:00.000Z',
|
||||
updatedAt: '2026-04-12T16:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
getContext: vi.fn(async () => ({
|
||||
transcriptFiles: ['/tmp/task.jsonl', '/tmp/alice.jsonl'],
|
||||
config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] },
|
||||
})),
|
||||
};
|
||||
const runtimeFallbackSource = {
|
||||
getTaskLogStream: vi.fn(async () => null),
|
||||
};
|
||||
const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]);
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
recordSource as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
detailSelector as never,
|
||||
{ buildBundleChunks } as never,
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
runtimeFallbackSource as never,
|
||||
{ getMembers: vi.fn(async () => [{ name: 'tom', providerId: 'codex' }]) } as never,
|
||||
{ getConfig: vi.fn(async () => null) } as never
|
||||
);
|
||||
|
||||
const response = await service.getTaskLogStream('demo', 'task-a');
|
||||
|
||||
expect(response.segments).toHaveLength(1);
|
||||
expect(response.segments[0]?.participantKey).toBe('member:alice');
|
||||
const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[];
|
||||
expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']);
|
||||
});
|
||||
|
||||
it('does not recover task_get logs from nested task refs in result payloads', async () => {
|
||||
const taskReader = {
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-a',
|
||||
displayId: 'abcd1234',
|
||||
owner: 'tom',
|
||||
status: 'completed',
|
||||
createdAt: '2026-04-12T16:00:00.000Z',
|
||||
updatedAt: '2026-04-12T16:05:00.000Z',
|
||||
},
|
||||
]),
|
||||
getDeletedTasks: vi.fn(async () => []),
|
||||
};
|
||||
const transcriptSourceLocator = {
|
||||
getContext: vi.fn(async () => ({
|
||||
transcriptFiles: ['/tmp/lead.jsonl'],
|
||||
config: {
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
})),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async () =>
|
||||
new Map<string, ParsedMessage[]>([
|
||||
[
|
||||
'/tmp/lead.jsonl',
|
||||
[
|
||||
{
|
||||
uuid: 'assistant-task-get',
|
||||
parentUuid: null,
|
||||
type: 'assistant' as const,
|
||||
timestamp: new Date('2026-04-12T16:01:00.000Z'),
|
||||
role: 'assistant',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_use',
|
||||
id: 'tool-task-get',
|
||||
name: 'task_get',
|
||||
input: { teamName: 'demo', taskId: 'parent-task' },
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [
|
||||
{
|
||||
id: 'tool-task-get',
|
||||
name: 'task_get',
|
||||
input: { teamName: 'demo', taskId: 'parent-task' },
|
||||
isTask: false,
|
||||
},
|
||||
],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: 'user-task-get-result',
|
||||
parentUuid: 'assistant-task-get',
|
||||
type: 'user' as const,
|
||||
timestamp: new Date('2026-04-12T16:01:01.000Z'),
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: 'tool-task-get',
|
||||
content: JSON.stringify({
|
||||
id: 'parent-task',
|
||||
displayId: 'parent',
|
||||
blockedBy: ['task-a'],
|
||||
}),
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId: 'tool-task-get',
|
||||
content: JSON.stringify({
|
||||
id: 'parent-task',
|
||||
displayId: 'parent',
|
||||
blockedBy: ['task-a'],
|
||||
}),
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
sourceToolUseID: 'tool-task-get',
|
||||
sourceToolAssistantUUID: 'assistant-task-get',
|
||||
toolUseResult: {
|
||||
toolUseId: 'tool-task-get',
|
||||
content: JSON.stringify({
|
||||
id: 'parent-task',
|
||||
displayId: 'parent',
|
||||
blockedBy: ['task-a'],
|
||||
}),
|
||||
},
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
],
|
||||
],
|
||||
])
|
||||
),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => {
|
||||
throw new Error('task_get result payload should not create recovered records');
|
||||
}),
|
||||
};
|
||||
const runtimeFallbackSource = {
|
||||
getTaskLogStream: vi.fn(async () => null),
|
||||
};
|
||||
|
||||
const service = new BoardTaskLogStreamService(
|
||||
{ getTaskRecords: vi.fn(async () => []) } as never,
|
||||
summarySelector as never,
|
||||
strictParser as never,
|
||||
undefined as never,
|
||||
undefined as never,
|
||||
taskReader as never,
|
||||
transcriptSourceLocator as never,
|
||||
runtimeFallbackSource as never
|
||||
);
|
||||
|
||||
await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({
|
||||
participants: [],
|
||||
defaultFilter: 'all',
|
||||
segments: [],
|
||||
});
|
||||
expect(summarySelector.selectSummaries).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('extracts task_add_comment text from json-like tool result payload', async () => {
|
||||
const tom = {
|
||||
memberName: 'tom',
|
||||
|
|
@ -1112,4 +1365,137 @@ describe('BoardTaskLogStreamService', () => {
|
|||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('sanitizes MCP task_complete and message_send json payloads into readable results', async () => {
|
||||
const tom = {
|
||||
memberName: 'tom',
|
||||
role: 'member' as const,
|
||||
sessionId: 'session-tom',
|
||||
isSidechain: false,
|
||||
};
|
||||
const completeCandidate = {
|
||||
...makeCandidate('c-complete', '2026-04-12T16:00:00.000Z', tom, 'tool-complete'),
|
||||
actionCategory: 'status' as const,
|
||||
canonicalToolName: 'task_complete',
|
||||
};
|
||||
const sendCandidate = {
|
||||
...makeCandidate('c-send', '2026-04-12T16:00:01.000Z', tom, 'tool-send'),
|
||||
actionCategory: 'other' as const,
|
||||
canonicalToolName: 'mcp__agent-teams__message_send',
|
||||
};
|
||||
|
||||
const recordSource = {
|
||||
getTaskRecords: vi.fn(async () => [
|
||||
...completeCandidate.records,
|
||||
...sendCandidate.records,
|
||||
]),
|
||||
};
|
||||
const summarySelector = {
|
||||
selectSummaries: vi.fn(() => [completeCandidate, sendCandidate]),
|
||||
};
|
||||
const strictParser = {
|
||||
parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])),
|
||||
};
|
||||
const detailSelector = {
|
||||
selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => {
|
||||
const isComplete = candidate.id === 'c-complete';
|
||||
const toolUseId = isComplete ? 'tool-complete' : 'tool-send';
|
||||
const toolName = isComplete ? 'task_complete' : 'mcp__agent-teams__message_send';
|
||||
const payload = isComplete
|
||||
? { id: 'task-a', displayId: 'abcd1234', status: 'completed' }
|
||||
: {
|
||||
deliveredToInbox: true,
|
||||
message: {
|
||||
from: 'tom',
|
||||
to: 'team-lead',
|
||||
text: 'Detailed body',
|
||||
summary: '#abcd1234 done',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
id: candidate.id,
|
||||
timestamp: candidate.timestamp,
|
||||
actor: tom,
|
||||
source: candidate.source,
|
||||
records: candidate.records,
|
||||
filteredMessages: [
|
||||
{
|
||||
uuid: `${candidate.id}-assistant`,
|
||||
parentUuid: null,
|
||||
type: 'assistant' as const,
|
||||
timestamp: new Date(candidate.timestamp),
|
||||
role: 'assistant',
|
||||
content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: {} } as never],
|
||||
toolCalls: [],
|
||||
toolResults: [],
|
||||
isSidechain: false,
|
||||
isMeta: false,
|
||||
isCompactSummary: false,
|
||||
},
|
||||
{
|
||||
uuid: `${candidate.id}-result`,
|
||||
parentUuid: `${candidate.id}-assistant`,
|
||||
type: 'user' as const,
|
||||
timestamp: new Date(candidate.timestamp),
|
||||
role: 'user',
|
||||
content: [
|
||||
{
|
||||
type: 'tool_result',
|
||||
tool_use_id: toolUseId,
|
||||
content: [{ type: 'text', text: JSON.stringify(payload) } as never],
|
||||
} as never,
|
||||
],
|
||||
toolCalls: [],
|
||||
toolResults: [
|
||||
{
|
||||
toolUseId,
|
||||
content: [{ type: 'text', text: JSON.stringify(payload) }],
|
||||
isError: false,
|
||||
},
|
||||
],
|
||||
sourceToolUseID: toolUseId,
|
||||
sourceToolAssistantUUID: `${candidate.id}-assistant`,
|
||||
toolUseResult: {
|
||||
toolUseId,
|
||||
content: JSON.stringify(payload),
|
||||
},
|
||||
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 completeResult = mergedMessages.find((message) => message.uuid === 'c-complete-result');
|
||||
const sendResult = mergedMessages.find((message) => message.uuid === 'c-send-result');
|
||||
expect(completeResult?.toolResults).toEqual([
|
||||
{
|
||||
toolUseId: 'tool-complete',
|
||||
content: 'Task abcd1234 completed',
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
expect(sendResult?.toolResults).toEqual([
|
||||
{
|
||||
toolUseId: 'tool-send',
|
||||
content: 'Message sent to team-lead - #abcd1234 done',
|
||||
isError: false,
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue