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();
|
return value.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isConventionalLeadName(value: string): boolean {
|
||||||
|
const normalized = normalizeDisplayRef(value);
|
||||||
|
return normalized === 'team-lead' || normalized === 'lead';
|
||||||
|
}
|
||||||
|
|
||||||
function looksLikeCanonicalTaskId(value: string): boolean {
|
function looksLikeCanonicalTaskId(value: string): boolean {
|
||||||
return CANONICAL_TASK_ID_PATTERN.test(value.trim());
|
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
|
typeof message.agentName === 'string' && message.agentName.trim().length > 0
|
||||||
? message.agentName.trim()
|
? message.agentName.trim()
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const role: BoardTaskActivityActor['role'] = memberName
|
||||||
|
? isConventionalLeadName(memberName)
|
||||||
|
? 'lead'
|
||||||
|
: 'member'
|
||||||
|
: message.isSidechain
|
||||||
|
? 'member'
|
||||||
|
: 'unknown';
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...(memberName ? { memberName } : {}),
|
...(memberName ? { memberName } : {}),
|
||||||
role: memberName
|
role,
|
||||||
? message.isSidechain
|
|
||||||
? 'member'
|
|
||||||
: 'lead'
|
|
||||||
: message.isSidechain
|
|
||||||
? 'member'
|
|
||||||
: 'unknown',
|
|
||||||
sessionId: message.sessionId,
|
sessionId: message.sessionId,
|
||||||
...(message.agentId ? { agentId: message.agentId } : {}),
|
...(message.agentId ? { agentId: message.agentId } : {}),
|
||||||
isSidechain: message.isSidechain,
|
isSidechain: message.isSidechain,
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import {
|
||||||
type ParsedBoardTaskLink,
|
type ParsedBoardTaskLink,
|
||||||
type ParsedBoardTaskToolAction,
|
type ParsedBoardTaskToolAction,
|
||||||
} from '../contract/BoardTaskTranscriptContract';
|
} from '../contract/BoardTaskTranscriptContract';
|
||||||
|
import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext';
|
||||||
|
|
||||||
import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache';
|
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;
|
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 {
|
export class BoardTaskActivityTranscriptReader {
|
||||||
private readonly cache = new BoardTaskActivityParseCache<RawTaskActivityMessage[]>();
|
private readonly cache = new BoardTaskActivityParseCache<RawTaskActivityMessage[]>();
|
||||||
|
|
||||||
|
|
@ -112,6 +119,7 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
|
|
||||||
private async parseFile(filePath: string): Promise<RawTaskActivityMessage[]> {
|
private async parseFile(filePath: string): Promise<RawTaskActivityMessage[]> {
|
||||||
const results: RawTaskActivityMessage[] = [];
|
const results: RawTaskActivityMessage[] = [];
|
||||||
|
const actorContextTracker = new TranscriptSessionActorContextTracker();
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: stream,
|
input: stream,
|
||||||
|
|
@ -123,7 +131,7 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
for await (const line of rl) {
|
for await (const line of rl) {
|
||||||
if (!line.trim()) continue;
|
if (!line.trim()) continue;
|
||||||
lineCount += 1;
|
lineCount += 1;
|
||||||
if (!line.includes('"boardTaskLinks"')) {
|
if (!lineMayContainTaskActivityOrActorContext(line)) {
|
||||||
if (lineCount % 500 === 0) {
|
if (lineCount % 500 === 0) {
|
||||||
await yieldToEventLoop();
|
await yieldToEventLoop();
|
||||||
}
|
}
|
||||||
|
|
@ -134,6 +142,11 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
const parsed = JSON.parse(line) as unknown;
|
const parsed = JSON.parse(line) as unknown;
|
||||||
const record = asRecord(parsed);
|
const record = asRecord(parsed);
|
||||||
if (!record) continue;
|
if (!record) continue;
|
||||||
|
actorContextTracker.remember(record);
|
||||||
|
|
||||||
|
if (!line.includes('"boardTaskLinks"')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
const uuid = typeof record.uuid === 'string' ? record.uuid : '';
|
const uuid = typeof record.uuid === 'string' ? record.uuid : '';
|
||||||
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : '';
|
const sessionId = typeof record.sessionId === 'string' ? record.sessionId : '';
|
||||||
|
|
@ -142,6 +155,7 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
|
|
||||||
const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks);
|
const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks);
|
||||||
if (boardTaskLinks.length === 0) continue;
|
if (boardTaskLinks.length === 0) continue;
|
||||||
|
const contextRecord = actorContextTracker.apply(record);
|
||||||
|
|
||||||
sourceOrder += 1;
|
sourceOrder += 1;
|
||||||
results.push({
|
results.push({
|
||||||
|
|
@ -149,9 +163,10 @@ export class BoardTaskActivityTranscriptReader {
|
||||||
uuid,
|
uuid,
|
||||||
timestamp,
|
timestamp,
|
||||||
sessionId,
|
sessionId,
|
||||||
agentId: typeof record.agentId === 'string' ? record.agentId : undefined,
|
agentId: typeof contextRecord.agentId === 'string' ? contextRecord.agentId : undefined,
|
||||||
agentName: typeof record.agentName === 'string' ? record.agentName : undefined,
|
agentName:
|
||||||
isSidechain: record.isSidechain === true,
|
typeof contextRecord.agentName === 'string' ? contextRecord.agentName : undefined,
|
||||||
|
isSidechain: contextRecord.isSidechain === true,
|
||||||
boardTaskLinks,
|
boardTaskLinks,
|
||||||
boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions),
|
boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions),
|
||||||
sourceOrder,
|
sourceOrder,
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,12 @@ function isEmptyToolPayload(value: unknown): boolean {
|
||||||
return false;
|
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(
|
function collectEmptyPayloadExamples(
|
||||||
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
|
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
|
||||||
): BoardTaskLogDiagnosticExample[] {
|
): BoardTaskLogDiagnosticExample[] {
|
||||||
|
|
@ -194,7 +200,7 @@ function collectEmptyPayloadExamples(
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const toolUseResult = message.toolUseResult;
|
const toolUseResult = asObjectRecord(message.toolUseResult);
|
||||||
if (!toolUseResult) {
|
if (!toolUseResult) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,150 @@ interface TentativeFilteredMessage {
|
||||||
matchedToolUseId?: string;
|
matchedToolUseId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToolAnchoredOutputMessage(
|
interface ToolAnchorScope {
|
||||||
message: ParsedMessage,
|
toolUseId?: string;
|
||||||
toolUseId: string | undefined
|
assistantUuids: Set<string>;
|
||||||
): boolean {
|
outputMessageUuids: Set<string>;
|
||||||
return Boolean(toolUseId && message.sourceToolUseID === toolUseId);
|
}
|
||||||
|
|
||||||
|
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(
|
function noteExactDiagnostic(
|
||||||
|
|
@ -120,16 +259,18 @@ function filterMessageForCandidate(args: {
|
||||||
message: ParsedMessage;
|
message: ParsedMessage;
|
||||||
candidate: BoardTaskExactLogBundleCandidate;
|
candidate: BoardTaskExactLogBundleCandidate;
|
||||||
explicitMessageIds: Set<string>;
|
explicitMessageIds: Set<string>;
|
||||||
|
toolAnchorScope: ToolAnchorScope;
|
||||||
}): TentativeFilteredMessage | null {
|
}): TentativeFilteredMessage | null {
|
||||||
const { message, candidate, explicitMessageIds } = args;
|
const { message, candidate, explicitMessageIds, toolAnchorScope } = args;
|
||||||
const explicitMessageLinked = explicitMessageIds.has(message.uuid);
|
const explicitMessageLinked = explicitMessageIds.has(message.uuid);
|
||||||
const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined;
|
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 (typeof message.content === 'string') {
|
||||||
if (!explicitMessageLinked && !anchoredOutputLinked) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return {
|
return {
|
||||||
original: message,
|
original: message,
|
||||||
filteredContent: message.content,
|
filteredContent: message.content,
|
||||||
|
|
@ -142,7 +283,7 @@ function filterMessageForCandidate(args: {
|
||||||
filteredBlocks = filterAssistantContent(
|
filteredBlocks = filterAssistantContent(
|
||||||
message.content,
|
message.content,
|
||||||
toolUseId,
|
toolUseId,
|
||||||
explicitMessageLinked || anchoredOutputLinked
|
explicitMessageLinked || toolLinked
|
||||||
);
|
);
|
||||||
} else if (message.type === 'user') {
|
} else if (message.type === 'user') {
|
||||||
filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked);
|
filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked);
|
||||||
|
|
@ -309,6 +450,11 @@ export class BoardTaskExactLogDetailSelector {
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid));
|
const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid));
|
||||||
|
const toolAnchorScope = buildToolAnchorScope({
|
||||||
|
candidate,
|
||||||
|
parsedMessages,
|
||||||
|
explicitMessageIds,
|
||||||
|
});
|
||||||
const tentative: TentativeFilteredMessage[] = [];
|
const tentative: TentativeFilteredMessage[] = [];
|
||||||
|
|
||||||
for (const message of parsedMessages) {
|
for (const message of parsedMessages) {
|
||||||
|
|
@ -316,6 +462,7 @@ export class BoardTaskExactLogDetailSelector {
|
||||||
message,
|
message,
|
||||||
candidate,
|
candidate,
|
||||||
explicitMessageIds,
|
explicitMessageIds,
|
||||||
|
toolAnchorScope,
|
||||||
});
|
});
|
||||||
if (filtered) {
|
if (filtered) {
|
||||||
tentative.push(filtered);
|
tentative.push(filtered);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,8 @@ import { createReadStream } from 'fs';
|
||||||
import * as fs from 'fs/promises';
|
import * as fs from 'fs/promises';
|
||||||
import * as readline from 'readline';
|
import * as readline from 'readline';
|
||||||
|
|
||||||
|
import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext';
|
||||||
|
|
||||||
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
|
import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache';
|
||||||
|
|
||||||
import type { ParsedMessage } from '@main/types';
|
import type { ParsedMessage } from '@main/types';
|
||||||
|
|
@ -106,6 +108,7 @@ export class BoardTaskExactLogStrictParser {
|
||||||
|
|
||||||
private async readStrictFile(filePath: string): Promise<ParsedMessage[]> {
|
private async readStrictFile(filePath: string): Promise<ParsedMessage[]> {
|
||||||
const results: ParsedMessage[] = [];
|
const results: ParsedMessage[] = [];
|
||||||
|
const actorContextTracker = new TranscriptSessionActorContextTracker();
|
||||||
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
const stream = createReadStream(filePath, { encoding: 'utf8' });
|
||||||
const rl = readline.createInterface({
|
const rl = readline.createInterface({
|
||||||
input: stream,
|
input: stream,
|
||||||
|
|
@ -124,7 +127,9 @@ export class BoardTaskExactLogStrictParser {
|
||||||
continue;
|
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) {
|
if (parsed) {
|
||||||
results.push(parsed);
|
results.push(parsed);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource';
|
||||||
|
|
||||||
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord';
|
||||||
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
|
import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes';
|
||||||
|
import type { TaskLogRuntimeStreamSource } from './TaskLogRuntimeStreamSource';
|
||||||
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types';
|
||||||
import type {
|
import type {
|
||||||
BoardTaskActivityCategory,
|
BoardTaskActivityCategory,
|
||||||
|
|
@ -59,7 +60,7 @@ interface TimeWindow {
|
||||||
interface StreamLayout {
|
interface StreamLayout {
|
||||||
participants: BoardTaskLogParticipant[];
|
participants: BoardTaskLogParticipant[];
|
||||||
visibleSlices: StreamSlice[];
|
visibleSlices: StreamSlice[];
|
||||||
shouldMergeOpenCodeRuntimeFallback?: boolean;
|
shouldMergeRuntimeFallback?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const logger = createLogger('Service:BoardTaskLogStreamService');
|
const logger = createLogger('Service:BoardTaskLogStreamService');
|
||||||
|
|
@ -90,6 +91,7 @@ const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([
|
||||||
'task_set_owner',
|
'task_set_owner',
|
||||||
'task_unlink',
|
'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']);
|
const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']);
|
||||||
|
|
||||||
function emptyResponse(): BoardTaskLogStreamResponse {
|
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 {
|
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||||
return value && typeof value === 'object' && !Array.isArray(value)
|
return value && typeof value === 'object' && !Array.isArray(value)
|
||||||
? (value as Record<string, unknown>)
|
? (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(
|
function extractBoardToolOutputText(
|
||||||
toolName: string | undefined,
|
toolName: string | undefined,
|
||||||
parsedPayload: unknown
|
parsedPayload: unknown
|
||||||
|
|
@ -527,7 +615,7 @@ function extractBoardToolOutputText(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const normalizedToolName = toolName.trim().toLowerCase();
|
const normalizedToolName = canonicalizeBoardToolName(toolName) ?? toolName.trim().toLowerCase();
|
||||||
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
|
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
|
||||||
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
||||||
const comment = payload.comment as Record<string, unknown> | undefined;
|
const comment = payload.comment as Record<string, unknown> | undefined;
|
||||||
|
|
@ -536,36 +624,20 @@ function extractBoardToolOutputText(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedToolName === 'sendmessage') {
|
if (normalizedToolName === 'task_complete') {
|
||||||
const routing = payload.routing as Record<string, unknown> | undefined;
|
return formatTaskStatusPayload(payload) ?? 'Task completed';
|
||||||
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 (deliveryMessage && summary) {
|
if (normalizedToolName === 'sendmessage' || normalizedToolName === 'message_send') {
|
||||||
return `${deliveryMessage} - ${summary}`;
|
return formatMessageSendPayload(payload);
|
||||||
}
|
}
|
||||||
if (summary && target) {
|
|
||||||
return `Message sent to ${target} - ${summary}`;
|
if (payload.message) {
|
||||||
}
|
return formatMessageSendPayload(payload);
|
||||||
if (summary) {
|
}
|
||||||
return summary;
|
|
||||||
}
|
if (payload.status) {
|
||||||
if (deliveryMessage) {
|
return formatTaskStatusPayload(payload);
|
||||||
return deliveryMessage;
|
|
||||||
}
|
|
||||||
if (target) {
|
|
||||||
return `Message sent to ${target}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -1303,7 +1375,10 @@ function collectExplicitToolUseIds(
|
||||||
|
|
||||||
function collectAllowedMemberNames(
|
function collectAllowedMemberNames(
|
||||||
task: TeamTask,
|
task: TeamTask,
|
||||||
records: { actor: { memberName?: string } }[]
|
records: {
|
||||||
|
actor: { memberName?: string };
|
||||||
|
action?: { category?: BoardTaskActivityCategory; canonicalToolName?: string };
|
||||||
|
}[]
|
||||||
): Set<string> {
|
): Set<string> {
|
||||||
const allowedNames = new Set<string>();
|
const allowedNames = new Set<string>();
|
||||||
|
|
||||||
|
|
@ -1312,6 +1387,14 @@ function collectAllowedMemberNames(
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const record of records) {
|
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) {
|
if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) {
|
||||||
allowedNames.add(normalizeMemberName(record.actor.memberName));
|
allowedNames.add(normalizeMemberName(record.actor.memberName));
|
||||||
}
|
}
|
||||||
|
|
@ -1523,7 +1606,7 @@ export class BoardTaskLogStreamService {
|
||||||
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(),
|
||||||
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
private readonly taskReader: TeamTaskReader = new TeamTaskReader(),
|
||||||
private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(),
|
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 membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(),
|
||||||
private readonly configReader: TeamConfigReader = new TeamConfigReader()
|
private readonly configReader: TeamConfigReader = new TeamConfigReader()
|
||||||
) {}
|
) {}
|
||||||
|
|
@ -1792,8 +1875,12 @@ export class BoardTaskLogStreamService {
|
||||||
|
|
||||||
const resultPayload = resolveToolResultPayload(message, toolResult);
|
const resultPayload = resolveToolResultPayload(message, toolResult);
|
||||||
if (
|
if (
|
||||||
!valueReferencesTask(toolCall.input, taskRefs) &&
|
!historicalBoardToolReferencesTask({
|
||||||
!valueReferencesTask(resultPayload, taskRefs)
|
canonicalToolName: toolCall.canonicalToolName,
|
||||||
|
input: toolCall.input,
|
||||||
|
resultPayload,
|
||||||
|
taskRefs,
|
||||||
|
})
|
||||||
) {
|
) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
@ -1983,15 +2070,11 @@ export class BoardTaskLogStreamService {
|
||||||
return {
|
return {
|
||||||
participants: buildOrderedParticipants(visibleSlices),
|
participants: buildOrderedParticipants(visibleSlices),
|
||||||
visibleSlices,
|
visibleSlices,
|
||||||
shouldMergeOpenCodeRuntimeFallback: await this.shouldMergeOpenCodeRuntimeFallback(
|
shouldMergeRuntimeFallback: await this.shouldMergeRuntimeFallback(teamName, taskId, records),
|
||||||
teamName,
|
|
||||||
taskId,
|
|
||||||
records
|
|
||||||
),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async shouldMergeOpenCodeRuntimeFallback(
|
private async shouldMergeRuntimeFallback(
|
||||||
teamName: string,
|
teamName: string,
|
||||||
taskId: string,
|
taskId: string,
|
||||||
records: BoardTaskActivityRecord[]
|
records: BoardTaskActivityRecord[]
|
||||||
|
|
@ -2032,7 +2115,7 @@ export class BoardTaskLogStreamService {
|
||||||
const elapsedMs = Date.now() - startedAt;
|
const elapsedMs = Date.now() - startedAt;
|
||||||
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) {
|
||||||
logger.warn(
|
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
|
fallback
|
||||||
)} elapsedMs=${elapsedMs}`
|
)} elapsedMs=${elapsedMs}`
|
||||||
);
|
);
|
||||||
|
|
@ -2121,7 +2204,7 @@ export class BoardTaskLogStreamService {
|
||||||
source: 'transcript',
|
source: 'transcript',
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!layout.shouldMergeOpenCodeRuntimeFallback) {
|
if (!layout.shouldMergeRuntimeFallback) {
|
||||||
return primaryResponse;
|
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');
|
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', () => {
|
it('never joins action payloads onto execution rows', () => {
|
||||||
const taskA = makeTask({
|
const taskA = makeTask({
|
||||||
id: '123e4567-e89b-12d3-a456-426614174000',
|
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');
|
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', () => {
|
it('drops stale derived tool metadata when a message-linked row survives filtering', () => {
|
||||||
const record = {
|
const record = {
|
||||||
...makeRecord(),
|
...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.stream.visibleToolNames).toContain('mcp__agent-teams__task_complete');
|
||||||
expect(report.diagnosis.join(' ')).not.toContain('Only board MCP actions are explicit');
|
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']);
|
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 () => {
|
it('extracts task_add_comment text from json-like tool result payload', async () => {
|
||||||
const tom = {
|
const tom = {
|
||||||
memberName: '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