From 39be3bce75e78d0ab70ac989a841ffccc75fc181 Mon Sep 17 00:00:00 2001 From: 777genius Date: Sat, 18 Apr 2026 13:11:13 +0300 Subject: [PATCH] fix(team): restore task log fallback and block launch auto-assignment --- .../services/team/TeamProvisioningService.ts | 24 +- .../stream/BoardTaskLogStreamService.ts | 338 +++++++++++++++++- src/main/types/jsonl.ts | 1 + src/main/types/messages.ts | 4 + src/main/utils/jsonl.ts | 6 + .../team/task-log-stream-fallback-real.jsonl | 10 + .../BoardTaskLogStreamIntegration.test.ts | 268 +++++++++++++- .../TeamProvisioningServicePrompts.test.ts | 8 +- test/main/utils/jsonl.test.ts | 43 +++ 9 files changed, 693 insertions(+), 9 deletions(-) create mode 100644 test/fixtures/team/task-log-stream-fallback-real.jsonl diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index 78852a60..6e4bfb47 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -1910,6 +1910,7 @@ function buildDeterministicLaunchHydrationPrompt( const userPromptBlock = request.prompt?.trim() ? `\nOriginal user instructions to apply after reconnect is stable:\n${request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const persistentContext = buildPersistentLeadContext({ teamName: request.teamName, @@ -1923,13 +1924,21 @@ Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT start implementation in this turn. Use this turn only to refresh context, review the current board snapshot, and confirm you are ready. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update board tasks for yourself, but do not begin executing them yet.` +${ + hasOriginalUserPrompt + ? 'Do NOT create or update any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +}` : `This reconnect/bootstrap step has already been completed deterministically by the runtime. Do NOT call TeamCreate. Do NOT use Agent to spawn or restore teammates. Do NOT repeat the launch summary. -Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step. -If the user instructions imply new substantial work that is not on the board yet, you MAY create or update team-board tasks and assign owners now, but do NOT start implementation work in this turn. +Use this turn only to refresh context and review the current board snapshot. +${ + hasOriginalUserPrompt + ? 'Do NOT create or assign any new task in this turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' +} Treat teammates whose bootstrap is still pending as not-yet-available for blocking assignments.`; return `${startLabel} [Deterministic reconnect | Team: "${request.teamName}" | Project: "${projectName}" | Lead: "${leadName}"] @@ -1956,6 +1965,7 @@ function buildGeminiPostLaunchHydrationPrompt( const userPromptBlock = run.request.prompt?.trim() ? `\nOriginal user instructions to apply now:\n${run.request.prompt.trim()}\n` : ''; + const hasOriginalUserPrompt = Boolean(run.request.prompt?.trim()); const taskBoardSnapshot = buildTaskBoardSnapshot(tasks); const teammateBootstrapSnapshot = members.length ? `Current teammate launch status:\n${members @@ -1984,8 +1994,12 @@ function buildGeminiPostLaunchHydrationPrompt( members, }); const nextStepInstruction = isSolo - ? 'From this point on, use the full operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now create board tasks for yourself, but do not start implementation in this context-refresh turn.' - : 'From this point on, use the full team operating rules below for all future turns. If the original user instructions describe substantial work that should be tracked, you MAY now translate them into board tasks and prepare delegation, but do not start implementation work in this context-refresh turn. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; + ? hasOriginalUserPrompt + ? 'From this point on, use the full operating rules below for all future turns. Do NOT create or update any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work.' + : 'From this point on, use the full operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction.' + : hasOriginalUserPrompt + ? 'From this point on, use the full team operating rules below for all future turns. Do NOT create or assign any new task in this context-refresh turn - wait for the next normal operating turn before translating those instructions into board work. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.' + : 'From this point on, use the full team operating rules below for all future turns. Do NOT create, assign, or delegate any new task in this context-refresh turn. If the board is empty, stay silent and wait for a fresh user instruction. Do NOT assume bootstrap-pending or failed teammates are ready; only treat teammates with confirmed bootstrap as immediately available for blocking assignments.'; return `Gemini launch phase 2 — operating context for team "${run.teamName}". diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 2a47c4fb..15157c8f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -1,6 +1,9 @@ import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { isLeadMember as isLeadMemberCheck } from '@shared/utils/leadDetection'; +import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; @@ -16,12 +19,14 @@ import type { BoardTaskLogParticipant, BoardTaskLogSegment, BoardTaskLogStreamResponse, + TeamTask, } from '@shared/types'; interface StreamSlice { id: string; timestamp: string; filePath: string; + sortOrder?: number; participantKey: string; actor: BoardTaskLogActor; actionCategory?: BoardTaskActivityCategory; @@ -37,6 +42,17 @@ interface MergedMessageAccumulator { toolUseResults: ToolUseResultData[]; } +interface TimeWindow { + startMs: number; + endMs: number | null; +} + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const INFERRED_WINDOW_GRACE_BEFORE_MS = 30_000; +const INFERRED_WINDOW_GRACE_AFTER_MS = 15_000; +const INFERRED_RECORD_RANGE_BEFORE_MS = 5 * 60_000; +const INFERRED_RECORD_RANGE_AFTER_MS = 60_000; + function emptyResponse(): BoardTaskLogStreamResponse { return { participants: [], @@ -49,6 +65,12 @@ function normalizeMemberName(value: string): string { return value.trim().toLowerCase(); } +function isBoardMcpToolName(toolName: string | undefined): boolean { + if (!toolName) return false; + const normalized = toolName.trim().toLowerCase(); + return BOARD_MCP_TOOL_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +} + function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { return { ...(detail.memberName ? { memberName: detail.memberName } : {}), @@ -691,15 +713,319 @@ function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; } +function buildToolNameByUseId( + parsedMessagesByFile: Map +): Map { + const toolNameByUseId = new Map(); + + for (const messages of parsedMessagesByFile.values()) { + for (const message of messages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + + return toolNameByUseId; +} + +function buildTaskTimeWindows(task: TeamTask, recordTimestamps: number[]): TimeWindow[] { + const windowsFromIntervals = (Array.isArray(task.workIntervals) ? task.workIntervals : []) + .map((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt)) { + return null; + } + const completedAt = + typeof interval.completedAt === 'string' ? Date.parse(interval.completedAt) : Number.NaN; + return { + startMs: startedAt - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(completedAt) ? completedAt + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }; + }) + .filter((window): window is TimeWindow => window !== null); + + if (windowsFromIntervals.length > 0) { + return windowsFromIntervals; + } + + const createdAtMs = typeof task.createdAt === 'string' ? Date.parse(task.createdAt) : Number.NaN; + const updatedAtMs = typeof task.updatedAt === 'string' ? Date.parse(task.updatedAt) : Number.NaN; + if (Number.isFinite(createdAtMs) || Number.isFinite(updatedAtMs)) { + const startMs = Number.isFinite(createdAtMs) ? createdAtMs : updatedAtMs; + return [ + { + startMs: startMs - INFERRED_WINDOW_GRACE_BEFORE_MS, + endMs: Number.isFinite(updatedAtMs) ? updatedAtMs + INFERRED_WINDOW_GRACE_AFTER_MS : null, + }, + ]; + } + + const finiteRecordTimestamps = recordTimestamps.filter((timestamp) => Number.isFinite(timestamp)); + if (finiteRecordTimestamps.length === 0) { + return []; + } + + return [ + { + startMs: Math.min(...finiteRecordTimestamps) - INFERRED_RECORD_RANGE_BEFORE_MS, + endMs: Math.max(...finiteRecordTimestamps) + INFERRED_RECORD_RANGE_AFTER_MS, + }, + ]; +} + +function isWithinTimeWindows(timestamp: Date, windows: TimeWindow[]): boolean { + const messageTime = timestamp.getTime(); + if (!Number.isFinite(messageTime)) { + return false; + } + if (windows.length === 0) { + return true; + } + + const now = Date.now(); + return windows.some((window) => { + const endMs = window.endMs ?? now; + return messageTime >= window.startMs && messageTime <= endMs; + }); +} + +function collectExplicitMessageIds(records: { source: { messageUuid: string } }[]): Set { + return new Set(records.map((record) => record.source.messageUuid)); +} + +function collectExplicitToolUseIds( + records: { + source: { toolUseId?: string }; + action?: { toolUseId?: string }; + }[] +): Set { + const toolUseIds = new Set(); + + for (const record of records) { + const sourceToolUseId = record.source.toolUseId?.trim(); + if (sourceToolUseId) { + toolUseIds.add(sourceToolUseId); + } + + const actionToolUseId = record.action?.toolUseId?.trim(); + if (actionToolUseId) { + toolUseIds.add(actionToolUseId); + } + } + + return toolUseIds; +} + +function collectAllowedMemberNames( + task: TeamTask, + records: { actor: { memberName?: string } }[] +): Set { + const allowedNames = new Set(); + + if (typeof task.owner === 'string' && task.owner.trim().length > 0) { + allowedNames.add(normalizeMemberName(task.owner)); + } + + for (const record of records) { + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { + allowedNames.add(normalizeMemberName(record.actor.memberName)); + } + } + + return allowedNames; +} + +function extractMessageToolUseIds(message: ParsedMessage): Set { + const toolUseIds = new Set(); + + for (const toolCall of message.toolCalls) { + if (typeof toolCall.id === 'string' && toolCall.id.trim().length > 0) { + toolUseIds.add(toolCall.id.trim()); + } + } + + for (const toolResult of message.toolResults) { + if (typeof toolResult.toolUseId === 'string' && toolResult.toolUseId.trim().length > 0) { + toolUseIds.add(toolResult.toolUseId.trim()); + } + } + + if (typeof message.sourceToolUseID === 'string' && message.sourceToolUseID.trim().length > 0) { + toolUseIds.add(message.sourceToolUseID.trim()); + } + + return toolUseIds; +} + +function messageHasNonBoardToolActivity( + message: ParsedMessage, + toolNameByUseId: Map +): boolean { + for (const toolCall of message.toolCalls) { + if (!isBoardMcpToolName(toolCall.name)) { + return true; + } + } + + for (const toolResult of message.toolResults) { + if (!isBoardMcpToolName(toolNameByUseId.get(toolResult.toolUseId))) { + return true; + } + } + + if (message.sourceToolUseID) { + const sourceToolName = toolNameByUseId.get(message.sourceToolUseID); + if (sourceToolName && !isBoardMcpToolName(sourceToolName)) { + return true; + } + } + + return false; +} + +function buildInferredActor(message: ParsedMessage, leadName: string): BoardTaskLogActor | null { + const sessionId = message.sessionId?.trim(); + if (!sessionId) { + return null; + } + + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + const isLead = + memberName != null && normalizeMemberName(memberName) === normalizeMemberName(leadName); + + return { + ...(memberName ? { memberName } : {}), + role: isLead ? 'lead' : memberName ? 'member' : message.isSidechain ? 'member' : 'unknown', + sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function compareSlices(left: StreamSlice, right: StreamSlice): number { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + if (left.filePath !== right.filePath) { + return left.filePath.localeCompare(right.filePath); + } + if ((left.sortOrder ?? 0) !== (right.sortOrder ?? 0)) { + return (left.sortOrder ?? 0) - (right.sortOrder ?? 0); + } + return left.id.localeCompare(right.id); +} + export class BoardTaskLogStreamService { constructor( private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector(), private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), private readonly detailSelector: BoardTaskExactLogDetailSelector = new BoardTaskExactLogDetailSelector(), - private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder() + private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator() ) {} + private async buildInferredExecutionSlices( + teamName: string, + taskId: string, + records: Awaited>, + parsedMessagesByFile: Map + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return []; + } + + const [activeTasks, deletedTasks, transcriptContext] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.getContext(teamName), + ]); + + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + if (!task) { + return []; + } + + const transcriptFiles = transcriptContext?.transcriptFiles ?? []; + const missingFiles = transcriptFiles.filter((filePath) => !parsedMessagesByFile.has(filePath)); + let mergedParsedMessagesByFile = parsedMessagesByFile; + if (missingFiles.length > 0) { + const additionalParsedMessages = await this.strictParser.parseFiles(missingFiles); + mergedParsedMessagesByFile = new Map([ + ...parsedMessagesByFile.entries(), + ...additionalParsedMessages.entries(), + ]); + } + + const toolNameByUseId = buildToolNameByUseId(mergedParsedMessagesByFile); + const recordTimestamps = records.map((record) => Date.parse(record.timestamp)); + const taskTimeWindows = buildTaskTimeWindows(task, recordTimestamps); + if (taskTimeWindows.length === 0) { + return []; + } + + const explicitMessageIds = collectExplicitMessageIds(records); + const explicitToolUseIds = collectExplicitToolUseIds(records); + const allowedMemberNames = collectAllowedMemberNames(task, records); + const leadName = + transcriptContext?.config.members + ?.find((member) => isLeadMemberCheck(member)) + ?.name?.trim() || 'team-lead'; + + const inferredSlices: StreamSlice[] = []; + for (const [filePath, messages] of mergedParsedMessagesByFile.entries()) { + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (explicitMessageIds.has(message.uuid)) { + continue; + } + if (!isWithinTimeWindows(message.timestamp, taskTimeWindows)) { + continue; + } + + const actor = buildInferredActor(message, leadName); + if (!actor || !actor.memberName) { + continue; + } + + if ( + allowedMemberNames.size > 0 && + !allowedMemberNames.has(normalizeMemberName(actor.memberName)) + ) { + continue; + } + + const messageToolUseIds = extractMessageToolUseIds(message); + if ([...messageToolUseIds].some((toolUseId) => explicitToolUseIds.has(toolUseId))) { + continue; + } + if (!messageHasNonBoardToolActivity(message, toolNameByUseId)) { + continue; + } + + inferredSlices.push({ + id: `inferred:${filePath}:${message.uuid}`, + timestamp: message.timestamp.toISOString(), + filePath, + sortOrder: index, + participantKey: buildParticipantKey(actor), + actor, + filteredMessages: [message], + }); + } + } + + return inferredSlices.sort(compareSlices); + } + async getTaskLogStream(teamName: string, taskId: string): Promise { if (!isBoardTaskExactLogsReadEnabled()) { return emptyResponse(); @@ -762,6 +1088,7 @@ export class BoardTaskLogStreamService { id: detail.id, timestamp: detail.timestamp, filePath: detail.source.filePath, + sortOrder: detail.source.sourceOrder, participantKey: buildParticipantKey(actor), actor, actionCategory: candidate.actionCategory, @@ -773,7 +1100,14 @@ export class BoardTaskLogStreamService { return emptyResponse(); } - const deNoisedSlices = filterReadOnlySlices(slices); + const inferredExecutionSlices = await this.buildInferredExecutionSlices( + teamName, + taskId, + records, + parsedMessagesByFile + ); + const combinedSlices = [...slices, ...inferredExecutionSlices].sort(compareSlices); + const deNoisedSlices = filterReadOnlySlices(combinedSlices); const namedParticipantSlices = deNoisedSlices.filter((slice) => hasNamedParticipant(slice.actor) diff --git a/src/main/types/jsonl.ts b/src/main/types/jsonl.ts index bc5c2da3..c745db54 100644 --- a/src/main/types/jsonl.ts +++ b/src/main/types/jsonl.ts @@ -145,6 +145,7 @@ interface ConversationalEntry extends BaseEntry { sessionId: string; version: string; gitBranch: string; + agentName?: string; slug?: string; } diff --git a/src/main/types/messages.ts b/src/main/types/messages.ts index 1b496d9c..12d87a2d 100644 --- a/src/main/types/messages.ts +++ b/src/main/types/messages.ts @@ -80,10 +80,14 @@ export interface ParsedMessage { // Metadata /** Current working directory when message was created */ cwd?: string; + /** Root/session identifier from transcript */ + sessionId?: string; /** Git branch context */ gitBranch?: string; /** Agent ID for subagent messages */ agentId?: string; + /** Human-readable agent/member name from transcript */ + agentName?: string; /** Whether this is a sidechain message */ isSidechain: boolean; /** Whether this is a meta message */ diff --git a/src/main/utils/jsonl.ts b/src/main/utils/jsonl.ts index 3a345d5d..17b249e6 100644 --- a/src/main/utils/jsonl.ts +++ b/src/main/utils/jsonl.ts @@ -240,8 +240,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { let model: string | undefined; let requestId: string | undefined; let cwd: string | undefined; + let sessionId: string | undefined; let gitBranch: string | undefined; let agentId: string | undefined; + let agentName: string | undefined; let isSidechain = false; let isMeta = false; let userType: string | undefined; @@ -255,10 +257,12 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { if (isConversationalEntry(entry)) { // Common properties from ConversationalEntry base cwd = entry.cwd; + sessionId = entry.sessionId; gitBranch = entry.gitBranch; isSidechain = entry.isSidechain ?? false; userType = entry.userType; parentUuid = entry.parentUuid ?? null; + agentName = entry.agentName; // Type-specific properties if (entry.type === 'user') { @@ -298,8 +302,10 @@ function parseChatHistoryEntry(entry: ChatHistoryEntry): ParsedMessage | null { model, // Metadata cwd, + sessionId, gitBranch, agentId, + agentName, isSidechain, isMeta, userType, diff --git a/test/fixtures/team/task-log-stream-fallback-real.jsonl b/test/fixtures/team/task-log-stream-fallback-real.jsonl new file mode 100644 index 00000000..63a0bd55 --- /dev/null +++ b/test/fixtures/team/task-log-stream-fallback-real.jsonl @@ -0,0 +1,10 @@ +{"parentUuid":null,"isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-start-real","timestamp":"2026-04-12T15:36:00.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-start-real","message":{"id":"msg-a-start-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":5},"content":[{"type":"tool_use","id":"call-task-start-real","name":"mcp__agent-teams__task_start","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-start-real","timestamp":"2026-04-12T15:36:00.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-start-real","sourceToolUseID":"call-task-start-real","toolUseResult":{"toolUseId":"call-task-start-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-task-start-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"idle"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-task-start-real","canonicalToolName":"task_start"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-task-start-real","content":"ok"}]}} +{"parentUuid":"u-start-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-real","timestamp":"2026-04-12T15:36:14.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-bash-real","message":{"id":"msg-a-bash-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":12,"output_tokens":7},"content":[{"type":"tool_use","id":"call-bash-real","name":"Bash","input":{"command":"pnpm test --filter signal-ops","description":"Run targeted tests"}}]}} +{"parentUuid":"a-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-real","timestamp":"2026-04-12T15:36:14.250Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-real","sourceToolUseID":"call-bash-real","toolUseResult":{"toolUseId":"call-bash-real","stdout":"tests ok","stderr":"","exitCode":0,"content":"tests ok"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-real","content":"tests ok"}]}} +{"parentUuid":"u-bash-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-comment-real","timestamp":"2026-04-12T15:36:30.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-comment-real","message":{"id":"msg-a-comment-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":11,"output_tokens":6},"content":[{"type":"tool_use","id":"call-comment-real","name":"mcp__agent-teams__task_add_comment","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7","text":"Diagnostics complete - passing targeted checks."}}]}} +{"parentUuid":"a-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-comment-real","timestamp":"2026-04-12T15:36:30.150Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-comment-real","sourceToolUseID":"call-comment-real","toolUseResult":{"toolUseId":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-comment-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"board_action","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-comment-real","canonicalToolName":"task_add_comment","resultRefs":{"commentId":"comment-real-1"}}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-comment-real","content":"{\"comment\":{\"id\":\"comment-real-1\",\"text\":\"Diagnostics complete - passing targeted checks.\"}}"}]}} +{"parentUuid":"u-comment-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-bash-alice-real","timestamp":"2026-04-12T15:36:35.000Z","agentName":"alice","teamName":"beacon-desk-2","requestId":"req-bash-alice-real","message":{"id":"msg-a-bash-alice-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":9,"output_tokens":4},"content":[{"type":"tool_use","id":"call-bash-alice-real","name":"Bash","input":{"command":"echo alien","description":"Unrelated command"}}]}} +{"parentUuid":"a-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-alice-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-bash-alice-real","timestamp":"2026-04-12T15:36:35.100Z","agentName":"alice","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-bash-alice-real","sourceToolUseID":"call-bash-alice-real","toolUseResult":{"toolUseId":"call-bash-alice-real","stdout":"alien","stderr":"","exitCode":0,"content":"alien"},"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-bash-alice-real","content":"alien"}]}} +{"parentUuid":"u-bash-alice-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"assistant","uuid":"a-complete-real","timestamp":"2026-04-12T15:36:45.000Z","agentName":"tom","teamName":"beacon-desk-2","requestId":"req-complete-real","message":{"id":"msg-a-complete-real","role":"assistant","model":"claude-sonnet-4-20250514","type":"message","stop_reason":"tool_use","stop_sequence":null,"usage":{"input_tokens":8,"output_tokens":4},"content":[{"type":"tool_use","id":"call-complete-real","name":"mcp__agent-teams__task_complete","input":{"teamName":"beacon-desk-2","taskId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"}}]}} +{"parentUuid":"a-complete-real","isSidechain":false,"userType":"external","cwd":"/Users/belief/dev/projects/claude/claude_team","sessionId":"session-tom-real","version":"1.0.0","gitBranch":"main","type":"user","uuid":"u-complete-real","timestamp":"2026-04-12T15:36:45.120Z","agentName":"tom","teamName":"beacon-desk-2","isMeta":true,"sourceToolAssistantUUID":"a-complete-real","sourceToolUseID":"call-complete-real","toolUseResult":{"toolUseId":"call-complete-real","content":"{\"id\":\"c414cd52\"}"},"boardTaskLinks":[{"schemaVersion":1,"toolUseId":"call-complete-real","task":{"ref":"c414cd52-470a-4b51-ae1e-e5250fff95d7","refKind":"canonical","canonicalId":"c414cd52-470a-4b51-ae1e-e5250fff95d7"},"targetRole":"subject","linkKind":"lifecycle","taskArgumentSlot":"taskId","actorContext":{"relation":"same_task"}}],"boardTaskToolActions":[{"schemaVersion":1,"toolUseId":"call-complete-real","canonicalToolName":"task_complete"}],"message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"call-complete-real","content":"ok"}]}} diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index cc05960e..40355e34 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { mkdtemp, readFile, rm, writeFile } from 'fs/promises'; import { tmpdir } from 'os'; import path from 'path'; @@ -13,6 +13,10 @@ import type { TeamTask } from '../../../../src/shared/types'; const TEAM_NAME = 'beacon-desk-2'; const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; +const REAL_FIXTURE_PATH = path.resolve( + process.cwd(), + 'test/fixtures/team/task-log-stream-fallback-real.jsonl', +); function createTask(overrides: Partial = {}): TeamTask { return { @@ -377,4 +381,266 @@ describe('BoardTaskLogStreamService integration', () => { expect(response.segments).toHaveLength(1); expect(commentResult).toBeUndefined(); }); + + it('falls back to task time-window worker logs when explicit execution links are missing', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-inferred-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T15:36:00.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-task-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:00.120Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-task-start', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-task-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-task-start', + content: '{"id":"c414cd52"}', + }, + }), + createAssistantEntry({ + uuid: 'a-bash', + timestamp: '2026-04-12T15:36:14.000Z', + requestId: 'req-bash', + content: [ + { + type: 'tool_use', + id: 'call-bash', + name: 'Bash', + input: { + command: 'pnpm test', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-bash', + timestamp: '2026-04-12T15:36:14.300Z', + sourceToolAssistantUUID: 'a-bash', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash', + content: 'tests ok', + }, + ], + toolUseResult: { + toolUseId: 'call-bash', + content: 'tests ok', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-complete', + content: [ + { + type: 'tool_use', + id: 'call-complete', + name: 'mcp__agent-teams__task_complete', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-complete', + timestamp: '2026-04-12T15:36:30.150Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: 'ok', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-complete', + canonicalToolName: 'task_complete', + }, + ], + toolUseResult: { + toolUseId: 'call-complete', + content: '{"id":"c414cd52"}', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(toolNames).toContain('Bash'); + expect(toolNames).toContain('mcp__agent-teams__task_complete'); + }); + + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const fixtureText = await readFile(REAL_FIXTURE_PATH, 'utf8'); + await writeFile(transcriptPath, fixtureText, 'utf8'); + + const task = createTask({ + owner: 'tom', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + }); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const bashCommands = rawMessages.flatMap((message) => + message.toolCalls + .filter((toolCall) => toolCall.name === 'Bash') + .map((toolCall) => String(toolCall.input.command ?? '')), + ); + + expect(response.participants.map((participant) => participant.label)).toEqual(['tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(bashCommands).toContain('pnpm test --filter signal-ops'); + expect(bashCommands).not.toContain('echo alien'); + expect(rawMessages.some((message) => message.uuid === 'u-bash-alice-real')).toBe(false); + }); }); diff --git a/test/main/services/team/TeamProvisioningServicePrompts.test.ts b/test/main/services/team/TeamProvisioningServicePrompts.test.ts index 7eef52f4..597b31bc 100644 --- a/test/main/services/team/TeamProvisioningServicePrompts.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrompts.test.ts @@ -215,6 +215,9 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT start implementation in this turn.'); expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and confirm you are ready.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain( 'review_request already notifies the reviewer, so do NOT send a second manual SendMessage for the same review request' ); @@ -473,7 +476,10 @@ describe('TeamProvisioningService prompt content (solo mode discipline)', () => const prompt = extractPromptFromBootstrapFile(); expect(prompt).toContain('This reconnect/bootstrap step has already been completed deterministically by the runtime.'); expect(prompt).toContain('Do NOT use Agent to spawn or restore teammates.'); - expect(prompt).toContain('Use this turn only to refresh context, review the current board snapshot, and prepare the next delegation step.'); + expect(prompt).toContain('Use this turn only to refresh context and review the current board snapshot.'); + expect(prompt).toContain( + 'Do NOT create, assign, or delegate any new task in this turn. If the board is empty, stay silent and wait for a fresh user instruction.' + ); expect(prompt).toContain('DELEGATION-FIRST (behavior rule for ALL future turns):'); expect(prompt).toContain(`AGENT_BLOCK_OPEN is exactly: ${AGENT_BLOCK_OPEN}`); expect(prompt).toContain(`AGENT_BLOCK_CLOSE is exactly: ${AGENT_BLOCK_CLOSE}`); diff --git a/test/main/utils/jsonl.test.ts b/test/main/utils/jsonl.test.ts index 561de38d..3d458fdf 100644 --- a/test/main/utils/jsonl.test.ts +++ b/test/main/utils/jsonl.test.ts @@ -233,5 +233,48 @@ describe('jsonl', () => { expect(parsed?.uuid).toBe('bom-1'); }); + + it('preserves real transcript metadata needed by task-log fallback selection', () => { + const parsed = parseJsonlLine( + JSON.stringify({ + parentUuid: 'assistant-1', + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-real-1', + version: '1.0.0', + gitBranch: 'main', + type: 'user', + uuid: 'user-real-1', + timestamp: '2026-04-12T15:36:14.250Z', + agentName: 'tom', + isMeta: true, + sourceToolAssistantUUID: 'assistant-1', + sourceToolUseID: 'call-bash-real', + toolUseResult: { + toolUseId: 'call-bash-real', + stdout: 'tests ok', + exitCode: 0, + }, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-bash-real', + content: 'tests ok', + }, + ], + }, + }), + ); + + expect(parsed?.sessionId).toBe('session-real-1'); + expect(parsed?.agentName).toBe('tom'); + expect(parsed?.isMeta).toBe(true); + expect(parsed?.sourceToolAssistantUUID).toBe('assistant-1'); + expect(parsed?.sourceToolUseID).toBe('call-bash-real'); + expect(parsed?.toolResults[0]?.toolUseId).toBe('call-bash-real'); + }); }); });