fix(team): preserve task log owners across codex transcripts

This commit is contained in:
777genius 2026-04-28 15:00:13 +03:00
parent 2c2f84a00e
commit 1f7f31f3ee
14 changed files with 1224 additions and 67 deletions

View 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;
}
}

View file

@ -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,

View file

@ -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,

View file

@ -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;
}

View file

@ -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);

View file

@ -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);
}

View file

@ -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;
}

View file

@ -0,0 +1,5 @@
import type { BoardTaskLogStreamResponse } from '@shared/types';
export interface TaskLogRuntimeStreamSource {
getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse | null>;
}

View file

@ -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',

View file

@ -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,
});
});
});

View file

@ -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(),

View file

@ -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'],
]);
});
});

View file

@ -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']);
});
});

View file

@ -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,
},
]);
});
});