diff --git a/docs/iterations/README.md b/docs/iterations/README.md index e809b9f9..efac21c7 100644 --- a/docs/iterations/README.md +++ b/docs/iterations/README.md @@ -10,10 +10,11 @@ - [Итерация 04 — Messaging + Review](./iteration-04-messaging-review.md) - [Итерация 05 — Testing + Polish](./iteration-05-testing-polish.md) - [Итерация 06 — Team Provisioning (Create Team из UI)](./iteration-06-team-provisioning.md) +- [Iteration 07 - Task Logs + Explicit Board Task Links](./iteration-07-task-logs-explicit-board-task-links.md) +- [Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer](./iteration-08-exact-task-logs-reuse-existing-renderer.md) ## Принципы - **Vertical slice**: в каждой итерации доводим минимум “end-to-end” (types → main → IPC → preload → renderer → UI) - **Чёткий scope**: у каждой итерации есть цели и не‑цели - **Definition of Done**: заранее фиксируем критерии готовности и ручную проверку - diff --git a/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md new file mode 100644 index 00000000..e1aa9032 --- /dev/null +++ b/docs/iterations/iteration-07-task-logs-explicit-board-task-links.md @@ -0,0 +1,2630 @@ +# Iteration 07 - Task Logs + Explicit Board Task Links + +> Historical note +> This document captures the planned scope and architecture at iteration time. +> It is not the source of truth for the final runtime contract. + +This iteration introduces a **new explicit task activity model** for team board tasks and keeps the current session-based execution logs as a **separate legacy block**. + +The goal is to stop reconstructing `task -> logs` mostly from heuristics and instead persist a small, explicit, board-task-specific linkage in runtime transcripts, then build a clean read model for the task popup UI. + +This iteration spans **two repos**: +- `agent_teams_orchestrator` - write-side runtime and transcript contract +- `claude_team` - read-side task activity feed and UI integration + +--- + +## Decision Record + +### Chosen direction + +- **New `Task Activity` feed** +- **Keep old `Execution Sessions` block**, but explicitly treat it as legacy/session-centric +- **Persist explicit board-task links in transcript JSONL** +- **Build a read model on top of those links** + +### Why this was chosen + +- The current `Execution Logs` view is fundamentally **session-centric** +- The new requirement is **event-centric**: + - "show all logs/actions related to task A" + - including actions performed by another actor while they were actively working on task B +- Mixing both into one model makes both of them worse + +### Rejected alternatives + +- **Replace `Execution Logs` entirely with one new event timeline** + - Too risky for first rollout + - Would throw away useful current session features +- **Keep only the old session logic and improve heuristics** + - Not reliable enough + - Does not solve cross-task board actions correctly +- **Use one single `taskContext` object per message** + - Breaks on multi-target tools such as `task_link` + - Becomes ambiguous too quickly + +--- + +## Goals + +- Add a **new explicit activity feed** for board tasks +- Keep the current **execution session logs** available as a separate legacy block +- Make task-log linkage **structural**, not mainly heuristic +- Make the new feed **explicit-link only in v1** +- Support: + - task lifecycle events + - ordinary execution logs during active task work + - board actions performed on a task by another actor + - review flow actions + - multi-target task tools where relevant + +--- + +## Non-Goals + +- Replacing the existing `Workflow History` timeline +- Deleting the current `Execution Sessions` logic +- Rebuilding all historical logs retroactively +- Stamping ambiguous lead free-text execution in v1 +- Reworking built-in `TaskCreate` / `TaskUpdate` into this domain + +This iteration is for **board-task activity only**, not generic task tooling. + +--- + +## What We Fixed Before This Iteration + +Before implementing this iteration, we fixed a real false-negative in the current modern MCP task boundary detection: + +- fully-qualified tool names such as `mcp__agent-teams__task_start` +- alternate normalized names such as `mcp__agent_teams__task_complete` + +The fix was intentionally narrow: +- one canonicalization helper for agent-teams MCP tool names +- structural boundary detection now sees modern MCP task markers + +This is a prerequisite hardening step, not the main solution for the new feed. + +--- + +## Core Architectural Decision + +Use **two levels of model**, not one: + +### 1. Persisted wire contract + +The runtime writes small, explicit, additive transcript fields: + +- `boardTaskLinks?: BoardTaskLinkV1[]` +- `boardTaskToolActions?: BoardTaskToolActionV1[]` + +Together these fields capture the **minimum durable truth**: +- which board task(s) this message is linked to +- what kind of link each task has to the message +- how the actor's active task state relates to each task at that moment +- what board-task tool action(s) the message represents, when the message contains successful tool results + +They are **not** UI objects. + +### 2. Read model + +`claude_team` reads transcript entries and builds: + +- `BoardTaskActivityEntry` + +This is the UI-facing model for the new task activity feed. + +This separation keeps the runtime contract stable while allowing the UI to evolve. + +--- + +## Layering and Isolation Rules + +These rules are part of the design, not optional cleanup. + +### 1. Persisted contract is not a UI DTO + +`boardTaskLinks[]` must remain a small runtime fact model. + +It should not grow UI-only fields such as: +- display labels +- actor names +- timestamps duplicated from transcript entries +- section-level rendering hints + +### 2. The new feed must not depend on legacy heuristics in v1 + +The new `Task Activity` feed should read **explicit links only**. + +That means: +- no mention-based guessing +- no owner/session overlap inference +- no work-interval heuristics inside the new feed + +Legacy heuristics remain available only inside the legacy execution-sessions block. + +### 3. Keep the old session code, but isolate it + +Do **not** delete the current execution-session code. + +Do **not** comment it out either. + +Instead: +- keep it behind a separate service boundary +- keep it rendered in a separate UI section +- treat it as compatibility/session-exploration logic, not as the new source of truth + +### 4. The popup composes two read models, not one mixed model + +The task popup should compose: +- explicit event-level task activity +- legacy session-level execution browsing + +It should **not** merge both into one array or one card list. + +--- + +## Naming Decisions + +### Persisted fields + +Use: + +- `boardTaskLinks` +- `boardTaskToolActions` + +Do **not** use: + +- `taskContext` +- `boardTaskContext` + +Why: +- one message can legitimately link to **multiple board tasks** +- `task_link` and `task_unlink` are the clearest example +- plural naming makes the model honest + +### Persisted types + +Use: + +- `BoardTaskLinkV1` +- `BoardTaskLocator` +- `BoardTaskToolActionV1` + +### Read model + +Use: + +- shared DTO: `BoardTaskActivityEntry` +- main service: `BoardTaskActivityService` +- transcript discovery service: `TeamTranscriptSourceLocator` + +### Renderer names + +Use: + +- outer section label: `Task Logs` +- user-facing subsection label: `Task Activity` +- renderer component: `TaskActivitySection` +- composed container: `TaskLogsPanel` + +### Legacy/session block + +Use: + +- `Execution Sessions` + +This keeps the old block clearly separate from the new activity feed. + +### Why not `TaskActivityTimeline` as the main internal name + +The repo already has: +- `ActivityTimeline` for team inbox/message activity +- `Workflow History` / `StatusHistoryTimeline` for board-state history + +Using `TaskActivityTimeline` as the main internal component name would make the codebase harder to scan. + +So: +- `Task Logs` is the better outer section label +- `Task Activity` stays the user-facing subsection label +- `TaskActivitySection` is the better internal renderer name + +--- + +## Domain Boundaries + +### Included + +Board task domain only: +- `task_*` MCP tools that operate on board tasks +- `review_*` MCP tools tied to a board task + +### Excluded + +Do not include in the new core: +- built-in `TaskCreate` +- built-in `TaskUpdate` +- generic inbox/message/process tools without task target + +Those can remain as legacy/fallback logic where needed, but they are not part of the new activity core. + +--- + +## Persisted Wire Contract + +### Transcript field + +Add an optional field to transcript messages in `agent_teams_orchestrator`: + +```ts +type BoardTaskLocator = { + ref: string + refKind: 'canonical' | 'display' | 'unknown' + canonicalId?: string +} + +type BoardTaskLinkV1 = { + schemaVersion: 1 + + task: BoardTaskLocator + + taskArgumentSlot?: 'taskId' | 'targetId' + + toolUseId?: string + + linkKind: 'execution' | 'lifecycle' | 'board_action' + + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } +} + +type BoardTaskToolActionV1 = { + schemaVersion: 1 + toolUseId: string + canonicalToolName: string + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + } + resultRefs?: { + commentId?: string + attachmentId?: string + filename?: string + } +} + +type TranscriptMessage = ExistingTranscriptMessage & { + boardTaskLinks?: BoardTaskLinkV1[] + boardTaskToolActions?: BoardTaskToolActionV1[] +} +``` + +### Why this shape + +- `task.ref` instead of unconditional `taskId` + - runtime input may contain display IDs + - do not lie about canonical identity + - store the normalized task reference without a leading `#` +- `schemaVersion` + - clearer than a generic nested `version` + - safer when transcript messages already contain their own top-level version fields +- `taskArgumentSlot` + - needed for multi-target tools + - aligns the persisted contract with the actual MCP input slots (`taskId` / `targetId`) + - clearer than `inputRole`, which is too easy to confuse with user/assistant message roles + - clearer than `toolArgumentRole`, because this is specifically the task-related argument slot + - should be omitted for ambient execution links that do not originate from a tool argument +- `toolUseId` + - needed to join task links to the exact `tool_result` block that produced them + - protects the contract when one transcript message contains multiple `tool_result` blocks +- `linkKind` + - distinguishes execution, lifecycle, and board actions +- `actorContext` + - captures the subtle "actor is currently active on another task" case +- `boardTaskToolActions` + - keeps message-level tool semantics out of the per-target link object + - avoids repeating the same tool metadata across multiple target links + - must be plural because a single user message can legitimately contain multiple `tool_result` blocks + - gives the read-side enough stable structure for rows such as owner/status/relationship/clarification changes without parsing free text + - can carry stable result references such as `commentId` / `attachmentId` when the tool returns them + - `canonicalToolName` should store the canonical bare board tool name after `agent-teams` MCP normalization + - `input` / `resultRefs` should stay minimal and semantic, not a dump of raw MCP input or raw tool result + - do not copy long free-text payloads such as comment text, review notes, or request-change prose into transcript metadata + - omit orchestration-only inputs already represented elsewhere, such as `from`, `actor`, `leadSessionId`, and `notifyOwner` + +### Important rule + +Do **not** duplicate in `boardTaskLinks` or `boardTaskToolActions`: +- timestamp +- sessionId +- agentId +- memberName +- teamName + +Those already exist on the transcript entry itself and should remain single-source. + +For read-side task popup queries, the team scope comes from the surrounding team-scoped query/file +discovery path, so `boardTaskLinks[]` does not need to repeat it. + +This is especially important because not every transcript path is guaranteed to stamp `teamName` +uniformly on every entry, particularly sidechain-oriented paths. + +### Metadata size budget + +The explicit contract must stay small enough to remain transcript-friendly. + +Recommended budget rules: +- at most one `BoardTaskToolActionV1` per `toolUseId` in one message +- keep `boardTaskLinks` to the minimal task-target set for that message +- never persist arbitrary free-text comment bodies, review prose, or task descriptions +- trim all persisted string identifiers +- suggested soft caps: + - `task.ref` / `canonicalId` / `toolUseId` / `canonicalToolName` - at most 128 chars + - `filename` - at most 256 chars + - enum-like fields only from explicit allow-lists + +If a value exceeds the budget: +- prefer omitting that optional field over truncating it into a misleading value +- for required identifiers, skip that object and emit debug diagnostics instead of persisting junk + +### Omit vs null policy + +Use omission by default for unknown or unavailable optional fields. + +Rules: +- use `undefined` / omitted for: + - `taskArgumentSlot` + - `toolUseId` on ambient execution links + - `canonicalId` when unresolved + - `actorContext.activeTask` + - `actorContext.activePhase` + - `actorContext.activeExecutionSeq` + - optional `input` / `resultRefs` fields that are not whitelisted for the current tool +- use explicit `null` only when the domain itself uses null as meaningful data: + - `input.owner = null` + - `input.clarification = null` + +Why: +- omission means "not available / not applicable" +- `null` means "explicitly cleared" +- mixing them loosely would make parser behavior and UI labels inconsistent + +### Invariants + +- every `boardTaskToolActions[*].toolUseId` should match at least one `boardTaskLinks[*].toolUseId` +- `boardTaskToolActions` must not appear without at least one `boardTaskLink` +- within one message, `boardTaskToolActions` should be unique by `toolUseId` +- `linkKind = 'execution'` is reserved for ambient execution rows in v1 +- `execution` links may carry `toolUseId` when they intentionally anchor a worker `tool_result` + row for exact task-log reconstruction +- therefore `execution` links should omit `taskArgumentSlot` +- `boardTaskToolActions` should only pair with sibling links whose `linkKind` is `lifecycle` or `board_action` +- `actorContext.activeTask` should only be set when `relation = 'other_active_task'` +- `actorContext.activePhase` / `actorContext.activeExecutionSeq` describe the actor's active scope, + not the target task's own identity +- for `linkKind = 'lifecycle'`, `actorContext` should reflect the actor state **before** the + lifecycle transition is applied +- within one message, emitted links should be unique by `(toolUseId ?? 'ambient', task.ref, taskArgumentSlot ?? 'none', linkKind)` +- ambient execution links should omit `taskArgumentSlot` +- tool-derived links should set `taskArgumentSlot = 'taskId'` for the primary task-argument slot +- `toolUseId` should still be omitted for ordinary conversational execution messages + +### Additive-safety note + +This is safe as additive transcript metadata because: +- `agent_teams_orchestrator` transcript messages already tolerate optional extra fields +- `claude_team` JSONL parsing is loose and ignores unknown fields until explicitly consumed + +### Version evolution policy + +- bump `schemaVersion` only for breaking meaning changes, not for additive optional fields +- additive optional fields within `BoardTaskLinkV1` / `BoardTaskToolActionV1` should remain on + version `1` +- a single message should not mix multiple schema versions for the same object family +- readers should accept the current version and ignore newer unknown versions object-by-object +- writers should emit exactly one stable version family at a time + +This keeps rollout and future migrations simple: +- old readers keep working by ignoring what they do not understand +- new readers can still salvage older transcript rows without rewriting history + +--- + +## Write-Side Emission Policy + +The runtime should emit explicit links only when it has reliable information. + +### Carrier-field rule + +On the write side, the cleanest implementation is to carry: + +- `boardTaskLinks?: BoardTaskLinkV1[]` +- `boardTaskToolActions?: BoardTaskToolActionV1[]` + +as internal transcript-only fields on runtime `Message` objects before persistence. + +Those carriers must be threaded through the message creation/normalization path for any message +types that can legitimately receive task metadata. + +That implies adding optional transcript-only fields to the orchestrator's internal message types, +not just to `TranscriptMessage`. + +This keeps the contract close to the message that will actually be persisted and avoids having a +separate side registry that can drift from message ordering. + +### Carrier propagation checkpoints + +The implementation should explicitly audit the runtime paths that rebuild messages rather than +assuming a new field on `TranscriptMessage` will survive automatically. + +At minimum, verify the carrier survives: +- message factory helpers such as `createUserMessage(...)` +- any assistant-message creation path that rebuilds plain objects +- message normalization paths that split multi-block messages into new message objects +- transcript logging cleanup paths before `insertMessageChain(...)` + +And the implementation should explicitly **not** leak transcript-only task metadata into: +- model payload normalization +- SDK/web message mappers +- any API-facing serialization path not intended for transcript persistence + +### V1 rules + +- stamp explicit task links on successful board-task `tool_result` messages +- stamp `boardTaskToolActions` only on successful board-task `tool_result` messages +- stamp ambient `execution` links only on ordinary conversational messages when the actor has exactly one active task +- do not rely on raw `tool_use` alone to claim lifecycle success +- do not attach ambient execution links to progress, attachment, system, or transcript-only meta scaffolding +- do not attach ambient execution links to assistant `tool_use` blocks or thinking-only assistant children after normalization +- do not ambient-stamp lead free-text execution in v1 +- dedupe lifecycle/action application by `(sessionId, agentId ?? 'main', toolUseId)` before mutating actor execution state or stamping transcript fields + +### Carrier placement matrix + +Allowed carrier placement by runtime message shape: + +- user `tool_result` message + - may carry `boardTaskLinks` + - may carry `boardTaskToolActions` +- ordinary user conversational message + - may carry ambient `boardTaskLinks` + - must not carry `boardTaskToolActions` +- ordinary assistant conversational message + - may carry ambient `boardTaskLinks` + - must not carry `boardTaskToolActions` +- assistant `tool_use` message + - must not carry either carrier family in v1 +- thinking-only assistant child + - must not carry either carrier family +- `progress`, `attachment`, `system`, `tombstone`, compact-boundary, and other non-conversational items + - must not carry either carrier family + +Read-side simplifying assumption enabled by this rule: +- `boardTaskToolActions` always means "this message contains a concrete successful board-tool result" +- ambient execution links only appear on human-readable conversational rows + +### Tool-result success matrix + +For v1 explicit stamping, treat a board-tool result as successful only when all of the following hold: + +- the message is a real user `tool_result` message, not a synthetic placeholder +- the `tool_result` block pairs to a real assistant `tool_use` +- the result is not an interrupt/reject/denial synthetic recovery block +- the execution outcome is semantically successful for that tool family + +Conservative success rules: +- paired MCP board-task tool result with no synthetic/error recovery markers + - emit `board_action` or `lifecycle` metadata +- paired board-task tool result that is denied, rejected, interrupted, synthetic, or otherwise unsuccessful + - emit no explicit board-task metadata in v1 +- unpaired `tool_result` + - emit no explicit board-task metadata in v1 +- ambient conversational message while one active task exists + - emit `execution` links only + +Important design choice: +- v1 does **not** model failed board actions as task-activity rows +- this is intentional to keep the first explicit feed highly reliable +- if failed-action visibility becomes important later, add a separate `failed_board_action` concept + instead of overloading the success-only v1 contract + +### Why this matters + +Tool success semantics differ across tool families, so the observer must decide after execution +outcome is known, not just from the attempted tool call. + +Also, some runtime paths - especially subagent-oriented ones - do not preserve rich structured +`toolUseResult` / `mcpMeta` all the way to transcript persistence. The explicit transcript fields +must therefore carry enough stable board-task semantics for the read-side to avoid reparsing +natural-language tool output. + +Just as importantly, repeated tool-result handling by the same `toolUseId` would create duplicated +lifecycle transitions and duplicated task-activity rows, so the observer has to dedupe before +state mutation. + +The `toolUseId` join key is also what keeps the contract correct when a single transcript message +contains more than one successful `tool_result` block. + +--- + +## Read Model + +`claude_team` should build a richer UI model: + +```ts +type BoardTaskActivityEntry = { + id: string + timestamp: string + + actor: { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + } + + task: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } + + action: { + canonicalToolName?: string + toolUseId?: string + category: + | 'status' + | 'review' + | 'comment' + | 'assignment' + | 'read' + | 'attachment' + | 'relationship' + | 'clarification' + | 'other' + peerTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric' + details?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + attachmentId?: string + filename?: string + } + } + + source: { + messageUuid: string + filePath: string + } +} +``` + +The read model should be derived, not persisted. + +`id` should be stable and deterministic, for example: +- `${messageUuid}:${action.toolUseId ?? 'ambient'}:${task.locator.ref}:${link.taskArgumentSlot ?? 'none'}:${linkKind}` + +This avoids duplicate-row key problems when one transcript message yields multiple task activity rows. + +This read model should stay **semantic**, not presentation-coupled. + +It is the right place to add: +- resolved actor identity +- resolved task references where possible +- action category +- actor/task relationship state +- relationship peer-task context derived from sibling links within the same message + +It is **not** the right place to hardcode: +- final display labels +- UI tone names +- renderer-specific row text + +The read model should **not** leak raw transport details such as `taskArgumentSlot` into renderer code. +For relationship tools, the builder should consume `taskArgumentSlot` from the persisted link and expose +semantic information instead: +- `peerTask` +- `relationshipPerspective` + +For non-relationship rows, `taskArgumentSlot` is internal transport detail only: +- ambient execution rows will usually have it omitted +- ordinary single-target tool rows may have `'task'` +- renderer code should not branch on it directly + +Mapping rules for relationship rows: +- `related` -> `relationshipPerspective = 'symmetric'` on both task popups +- `blocked-by` on the `task` side -> `incoming` +- `blocked-by` on the `target` side -> `outgoing` +- `blocks` on the `task` side -> `outgoing` +- `blocks` on the `target` side -> `incoming` + +Whenever possible, the read-side builder should resolve persisted locators into the app's existing +shared `TaskRef` semantics for rendering and navigation. + +If resolution fails, it should keep the raw locator for fallback display instead of dropping the row. + +### Task resolution policy + +This is one of the highest-risk read-side areas. + +The builder must never silently guess a task from a weak locator. + +Rules: +- canonical identity always wins: + - `locator.canonicalId` + - then `refKind = 'canonical'` +- display-form resolution is allowed only when it resolves to **exactly one** candidate in team scope +- if multiple candidates share the same display-like ref, mark the row `resolution = 'ambiguous'` + and keep only the raw locator +- if no candidate matches, mark the row `resolution = 'unresolved'` +- if the best unique candidate exists only in deleted tasks, keep `taskRef` but mark + `resolution = 'deleted'` +- never drop a row only because the task cannot be resolved to a live `TaskRef` +- renderer navigation should rely on both `taskRef` and `resolution` +- in v1, rows with `resolution = 'deleted' | 'unresolved' | 'ambiguous'` should render as + non-primary navigation targets even if a fallback `taskRef` exists for label purposes + +Lookup scope: +- build the lookup from both active tasks and deleted tasks +- deleted tasks are needed mainly for: + - historical relationship rows + - lifecycle/action rows targeting tasks that were later deleted + - peer-task rendering for old `task_link` / `task_unlink` history + +Anti-guessing rule: +- do not use `Map` for display-id resolution +- display-like refs must resolve through a candidate set, not `last wins` +- if an `unknown` ref could be both a canonical-looking id and a display-like id, prefer exact + canonical-id lookup first, then unique display resolution, otherwise stay unresolved + +This policy should explicitly reuse existing shared task-identity rules where possible: +- `looksLikeCanonicalTaskId(...)` +- `getTaskDisplayId(...)` + +--- + +## UI Structure + +In the task popup, the current `Execution Logs` section should become a composed panel: + +- `Task Activity` +- `Execution Sessions` + +Target end state: +- outer collapsible title = `Task Logs` +- inner subsections = `Task Activity` and `Execution Sessions` + +For rollout stability, the outer collapsible title may temporarily remain `Execution Logs`, +but the plan target should still be `Task Logs`. + +Inside that block, the composed content should clearly separate: +- `Task Activity` +- `Execution Sessions` + +This preserves user familiarity while still introducing the new model cleanly. + +### Task Activity + +New feed based only on explicit `boardTaskLinks` plus message-level `boardTaskToolActions` + +Shows: +- lifecycle events +- execution-linked activity +- related board actions on this task + +This section complements `Workflow History`, not replaces it: +- `Workflow History` remains the authoritative board-state timeline +- `Task Activity` becomes the runtime provenance feed + +Empty-state guidance: +- if no explicit activity exists for a task, render an explicit empty state instead of silently collapsing the section +- the copy should explain that older sessions may still be available below in `Execution Sessions` + +Resolution display guidance: +- `resolution = 'active'` + - render normal task label/navigation behavior +- `resolution = 'deleted'` + - render deleted-state badge or muted label + - do not present it as a normal clickable live-task target in v1 +- `resolution = 'unresolved' | 'ambiguous'` + - render raw locator fallback + - avoid deep-link navigation because the target identity is not reliable + +### Execution Sessions + +Keep the current session-based block, powered by the existing `MemberLogsTab` + +Purpose: +- full transcript viewing +- current previews +- chunk filtering +- session-level exploration + +This block should be clearly treated as **legacy/session-centric**, not the new source of truth for task activity. + +Important UI rule: +- execution-specific polling affordances such as `Updating...` / `Online` belong to the `Execution Sessions` subsection only +- they should not be used as the loading or freshness indicator for the whole `Task Logs` panel + +--- + +## Why We Are Not Replacing the Old Block + +The current execution-log UI is useful, but it is solving a different problem: + +- it groups by session +- it sorts by work-interval overlap +- it filters chunks by persisted work intervals + +That is good for execution sessions, but not enough for task activity provenance. + +Trying to make one model serve both purposes creates: +- misleading activity feeds +- hidden related actions from other actors +- more heuristics +- harder maintenance + +So the correct design is **parallel, not replacement**. + +--- + +## Tool Classification + +All tool names in this section refer to the **canonical bare board-tool name** after `agent-teams` MCP name normalization. + +### Lifecycle + +These create `linkKind = 'lifecycle'`: + +- `task_start` +- `task_complete` +- `task_set_status` +- `review_start` +- `review_approve` +- `review_request_changes` + +### Board actions + +These create `linkKind = 'board_action'`: + +- `task_add_comment` +- `task_get_comment` +- `task_set_owner` +- `task_attach_file` +- `task_attach_comment_file` +- `task_link` +- `task_unlink` +- `task_set_clarification` +- `review_request` + +### Low-signal reads + +These are still explicit links, but may be visually muted or collapsible: + +- `task_get` + +### Ignored in v1 + +- `task_create` +- `task_create_from_message` +- `task_list` +- `task_briefing` +- `member_briefing` +- broad process/message tools without explicit `taskId` + +--- + +## Execution State Rules + +The runtime must not keep a naive single `currentTask`. + +Instead it should keep an execution scope per actor: + +- key = `(sessionId, agentId ?? 'main')` + +State should track: +- open active task set +- active phase (`work` or `review`) +- execution sequence number + +### Safe stamping rules + +- `0` active tasks + - no ambient execution link +- `1` active task + - ambient execution link allowed +- `2+` active tasks + - relation becomes `ambiguous` + - do not guess + +### Important rule + +For lifecycle messages: +- stamp the link from the explicit tool target first +- then update the actor execution state + +This ensures the lifecycle message itself is always linked to the correct task. + +--- + +## Review Flow Rules + +Review is part of the board-task activity domain and must be modeled explicitly. + +### Rules + +- `review_request` + - `board_action` + - does **not** open review execution +- `review_start` + - `lifecycle` + - may open review execution for the reviewer +- `review_approve` + - `lifecycle` + - closes review execution +- `review_request_changes` + - `lifecycle` + - closes review execution + +This keeps reviewer activity structurally visible instead of forcing it through status heuristics. + +--- + +## Multi-Target Tools + +### `task_link` / `task_unlink` + +These should emit **two links** when both task references are resolved: + +- one with `taskArgumentSlot = 'taskId'` +- one with `taskArgumentSlot = 'targetId'` + +This is the strongest reason to use `boardTaskLinks[]` instead of a single object. + +On the read side, the builder should combine sibling links from the same transcript message so each +rendered row can expose: +- the current task +- the peer task +- the relationship perspective for the current task + +That avoids forcing renderer code to understand raw MCP input roles. + +The `BoardTaskToolActionV1.input.relationship` value plus the persisted `taskArgumentSlot` should be +enough for the builder to derive relationship direction without re-reading task files. + +--- + +## Edge Cases + +### Another actor updates a task + +Example: +- Bob is actively working on task B +- Bob calls `task_add_comment` on task A + +Expected result: +- task A activity feed shows the event +- task B can continue to show Bob's own execution session separately in the legacy block +- event is marked as a related board action from another active task +- it is **not** shown as execution of task A + +### Lead mixed stream + +In v1: +- do not ambient-stamp lead free-text execution +- do allow explicit lifecycle and board-action links from lead tool calls + +### Ambiguous execution state + +If the actor has multiple active tasks: +- do not guess +- stamp explicit target links only +- use `relation = 'ambiguous'` + +### Idle actor + +If the actor is not actively executing any task but performs a task tool call: +- use `relation = 'idle'` + +### Historical logs + +Old logs without `boardTaskLinks` remain supported through: +- legacy execution sessions +- existing fallback logic where still needed + +The new activity feed in v1 should use explicit links only. + +### Multi-target relationship actions + +For `task_link` / `task_unlink`: +- the task popup for the `taskId` side should render the relationship from that task's perspective +- the related task popup for the `targetId` side should render the mirrored relationship from the peer-task perspective +- the UI label should make the relationship direction clear instead of rendering both rows identically + +--- + +## Implementation Structure + +### `agent_teams_orchestrator` + +Create a dedicated feature area: + +- `src/services/boardTaskActivity/contract.ts` +- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts` +- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts` +- `src/services/boardTaskActivity/BoardTaskTranscriptProjector.ts` +- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts` +- `src/services/boardTaskActivity/QueryBoardTaskObserver.ts` + +Responsibilities: +- inspect board MCP tool semantics +- maintain actor execution state +- produce `boardTaskLinks[]` +- produce `boardTaskToolActions[]` where applicable +- attach transcript-only task metadata before persistence + +Implementation note: +- thread the internal carrier field through the runtime message helpers before `insertMessageChain(...)` +- avoid computing task links late inside persistence from mutable global state + +### `claude_team` + +Create a separate task-log feature area: + +- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts` +- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts` +- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts` +- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts` +- `src/main/ipc/teams.ts` - add a dedicated `getTaskActivity` handler +- `src/main/ipc/handlers.ts` - register / remove the new handler with existing team IPC initialization + +Shared types: + +- `src/shared/types/team.ts` - add `BoardTaskActivityEntry` and related IPC-visible types +- `src/shared/types/api.ts` - add `teams.getTaskActivity(...)` +- `src/preload/constants/ipcChannels.ts` - add `TEAM_GET_TASK_ACTIVITY` +- `src/preload/index.ts` - expose the new preload method +- `src/renderer/api/httpClient.ts` - add browser-mode fallback for `getTaskActivity` + +Renderer: + +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` +- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx` +- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx` +- `src/renderer/components/team/taskLogs/taskActivityPresentation.ts` + +### API separation + +Do **not** overload the existing legacy API method. + +Keep: +- `teams.getLogsForTask(...)` for legacy execution sessions + +Add: +- `teams.getTaskActivity(teamName, taskId)` for the new explicit activity model + +This separation keeps the new model isolated from the old heuristic/session path. + +For the first rollout, this API can follow the same availability profile as the current +task-log endpoints: +- supported in Electron +- browser-mode HTTP client can return `[]` with a warning, matching the current task-log API pattern + +### Contract discipline + +To keep both repos aligned without over-coupling them: + +- define JSON schemas for `BoardTaskLinkV1` and `BoardTaskToolActionV1` +- mirror the TypeScript type locally in each repo +- add golden fixtures for representative cases in both repos +- keep transcript-contract mirror types main-process-only in `claude_team` +- keep `BoardTaskActivityEntry` and other IPC-visible DTOs in shared preload/renderer types + +Parsing tolerance rules: +- parse `boardTaskLinks` and `boardTaskToolActions` defensively and independently +- if one link object is malformed, drop only that link, not the whole transcript message +- if one action object is malformed, drop only that action, not the whole transcript message +- if `schemaVersion` is unknown, skip that object family and keep the rest of the message readable +- if a link references a `toolUseId` with no surviving action, the row may still be rendered from the + link alone +- if an action survives but no links survive for its `toolUseId`, ignore the action for feed-building + and optionally emit a debug log + +This keeps the explicit feed resilient against partial writes, old transcripts, or future schema +extensions that the current reader does not understand yet. + +Minimum fixture set: +- same-task execution +- one message with multiple board-task tool results joined by distinct `toolUseId` +- lifecycle by another actor while active on a different task +- board action by another actor while active on a different task +- review start / review completion +- task link dual-target emission +- relationship row with derived peer task and relationship perspective +- task relationship subtype payload +- status / owner / clarification action payload +- unresolved display-only task locator +- display-id collision produces `resolution = 'ambiguous'` +- deleted task locator produces `resolution = 'deleted'` without dropping the row +- unknown refKind that looks canonical resolves by exact id before any display fallback +- ambiguous actor context +- legacy entry without explicit links + +--- + +## Concrete Code Blueprint + +This section is intentionally implementation-oriented. The goal is to remove as much ambiguity as +possible before coding starts. + +### `agent_teams_orchestrator` - exact touchpoints + +#### 1. Transcript contract types + +File: +- `src/services/boardTaskActivity/contract.ts` +- `src/types/logs.ts` + +Add: + +```ts +export type BoardTaskLocator = { + ref: string + refKind: 'canonical' | 'display' | 'unknown' + canonicalId?: string +} + +export type BoardTaskLinkV1 = { + schemaVersion: 1 + task: BoardTaskLocator + taskArgumentSlot?: 'taskId' | 'targetId' + toolUseId?: string + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } +} + +export type BoardTaskToolActionV1 = { + schemaVersion: 1 + toolUseId: string + canonicalToolName: string + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + owner?: string | null + relationship?: 'blocked-by' | 'blocks' | 'related' + clarification?: 'lead' | 'user' | null + reviewer?: string + commentId?: string + } + resultRefs?: { + commentId?: string + attachmentId?: string + filename?: string + } +} +``` + +Extend `TranscriptMessage` in `src/types/logs.ts` with: + +```ts +boardTaskLinks?: BoardTaskLinkV1[] +boardTaskToolActions?: BoardTaskToolActionV1[] +``` + +Preferred reusable carrier type: + +```ts +export type BoardTaskCarrierFields = { + boardTaskLinks?: BoardTaskLinkV1[] + boardTaskToolActions?: BoardTaskToolActionV1[] +} +``` + +Implementation preference: +- prefer one shared `BoardTaskCarrierFields` mixin over repeating the same optional fields across + every helper and every runtime message type by hand +- if the actual runtime message owner file can be updated cleanly, extend the owner types with this + mixin once +- if the owner path is awkward or generated, use local intersection types at helper boundaries + instead of falling back to `any` +- keep these carrier fields runtime-internal and transcript-oriented, not part of API/model payloads + +Preferred blast-radius-minimizing strategy: + +```ts +type TaskAwareMessage = Message & BoardTaskCarrierFields +type TaskAwareUserMessage = UserMessage & BoardTaskCarrierFields +type TaskAwareAssistantMessage = AssistantMessage & + Pick +``` + +Use these local aliases first in: +- `createUserMessage(...)` +- `baseCreateAssistantMessage(...)` +- `emitTaskAware(...)` +- `insertMessageChain(...)` + +Why this is safer for v1: +- it localizes type churn to the board-task feature path +- it avoids blocking the whole rollout on the unresolved physical owner path for `types/message` +- it reduces the chance of breaking unrelated call sites that only know about plain `Message` +- it still keeps transcript persistence explicit and typed + +Only after the feature works end-to-end should we consider merging the mixin into the canonical +runtime message owner types everywhere, and only if that cleanup actually reduces complexity. + +#### 2. Internal message carriers + +File: +- `src/utils/messages.ts` + +Concrete changes: +- introduce or import `BoardTaskCarrierFields` +- extend `createUserMessage(...)` params with that mixin +- extend the runtime `Message` / `UserMessage` / `AssistantMessage` type definitions with the same + mixin only if the actual owner path makes that straightforward +- follow the actual import target used by `src/utils/messages.ts` for those runtime message types + instead of assuming the owner file path from memory +- add those fields onto the returned runtime message object +- extend the assistant message creation path with the same carrier mixin for ambient execution + stamping on assistant conversational messages +- the likely concrete touchpoint is `baseCreateAssistantMessage(...)`, because assistant helpers + already funnel through it +- ensure `normalizeMessages(...)` assistant split path preserves ambient `boardTaskLinks` on + conversational assistant text children instead of silently dropping them +- in the user normalization path that rebuilds per-block messages, pass those fields through when + calling `createUserMessage(...)` + +Pseudo-shape: + +```ts +export function createUserMessage({ + ..., + boardTaskLinks, + boardTaskToolActions, +}: { + ... +} & BoardTaskCarrierFields): TaskAwareUserMessage { + return { + ..., + boardTaskLinks, + boardTaskToolActions, + } +} +``` + +For assistant helpers, the concrete shape should be parallel: + +```ts +function baseCreateAssistantMessage({ + ..., + boardTaskLinks, +}: { + ... + boardTaskLinks?: BoardTaskLinkV1[] +}): TaskAwareAssistantMessage { + return { + ..., + boardTaskLinks, + } +} +``` + +And in the normalization split path: + +```ts +return { + ...createUserMessage({ + content: [_], + ..., + boardTaskLinks: filteredBoardTaskLinksForBlock(message.boardTaskLinks, _), + boardTaskToolActions: filteredBoardTaskToolActionsForBlock(message.boardTaskToolActions, _), + }), + uuid: ..., +} +``` + +Suggested helpers: + +```ts +function filteredBoardTaskLinksForBlock( + links: BoardTaskLinkV1[] | undefined, + block: ContentBlockParam, +): BoardTaskLinkV1[] | undefined { + if (!links?.length) return undefined + if (block.type === 'tool_result') { + const matching = links.filter(link => link.toolUseId === block.tool_use_id) + return matching.length > 0 ? matching : undefined + } + const ambient = links.filter(link => link.toolUseId === undefined) + return ambient.length > 0 ? ambient : undefined +} + +function filteredBoardTaskToolActionsForBlock( + actions: BoardTaskToolActionV1[] | undefined, + block: ContentBlockParam, +): BoardTaskToolActionV1[] | undefined { + if (!actions?.length) return undefined + if (block.type !== 'tool_result') return undefined + const matching = actions.filter(action => action.toolUseId === block.tool_use_id) + return matching.length > 0 ? matching : undefined +} +``` + +Filtering rule for split messages: +- if `_` is a `tool_result`, carry only links/actions whose `toolUseId` matches that block +- if `_` is ordinary conversational content, carry only ambient execution links where `toolUseId` is absent +- do not blindly copy the full arrays to every split child message + +Without this rule, one split `tool_result` child can silently inherit metadata that belongs to a +different `tool_result` block from the same original message. + +Why here: +- `normalizeMessagesForAPI(...)` rebuilds user messages +- if the carrier is not passed through here, transcript metadata will silently disappear on + multi-block user messages +- ordinary conversational task activity can also land on assistant messages, so the assistant + creation path must be able to carry `boardTaskLinks` +- but the assistant split path should keep ambient execution links only on human-readable + conversational children, not on `tool_use` or thinking-only children + +#### 3. Central tool-name normalization + +Files: +- `src/services/mcp/mcpStringUtils.ts` +- `src/Tool.ts` + +Concrete rule: +- do not add handwritten regexes for `mcp__agent-teams__...` +- use `mcpInfoFromString(...)` and/or the same canonicalization semantics as `toolMatchesName(...)` + +Recommended helper in `BoardTaskToolInterpreter.ts`: + +```ts +function canonicalizeBoardToolName(rawName: string): string | null { + const info = mcpInfoFromString(rawName) + if (!info?.toolName) { + return rawName.startsWith('task_') || rawName.startsWith('review_') + ? rawName + : null + } + const normalizedServer = info.serverName.replace(/[-_]+/g, '_') + if (normalizedServer !== 'agent_teams') return null + return info.toolName +} +``` + +#### 4. Execution state store + +Files: +- `src/services/boardTaskActivity/RuntimeBoardTaskExecutionStore.ts` +- `src/services/boardTaskActivity/BoardTaskExecutionReducer.ts` + +Suggested state: + +```ts +type ActorExecutionState = { + openTasks: Map + appliedToolUseIds: Set +} +``` + +Key the store by: + +```ts +`${sessionId}:${agentId ?? 'main'}` +``` + +Reducer API: + +```ts +applyLifecycle( + state: ActorExecutionState, + event: { + toolUseId: string + task: BoardTaskLocator + event: + | 'task_start' + | 'task_complete' + | 'task_set_status' + | 'review_start' + | 'review_approve' + | 'review_request_changes' + status?: 'pending' | 'in_progress' | 'completed' | 'deleted' + } +): ActorExecutionState +``` + +Important reducer rules: +- no-op if `toolUseId` already applied +- `task_start` and `task_set_status(in_progress)` open work execution +- `task_complete` and `task_set_status(completed|pending|deleted)` close work execution +- `review_start` opens review execution +- `review_approve` and `review_request_changes` close review execution +- never guess when `openTasks.size > 1` + +#### 5. Tool interpreter + +File: +- `src/services/boardTaskActivity/BoardTaskToolInterpreter.ts` + +Recommended public API: + +```ts +class BoardTaskToolInterpreter { + interpretToolResult(params: { + rawToolName: string + toolUseId: string + input: Record + result: unknown + }): { + canonicalToolName: string | null + links: BoardTaskLinkV1[] + actions: BoardTaskToolActionV1[] + lifecycleEvent?: LifecycleEvent + } +} +``` + +Why `Interpreter` is the safer name: +- this module does more than assign a category +- it interprets raw tool name + input + result into domain semantics: + - canonical tool identity + - target task locator(s) + - emitted task links + - emitted tool actions + - optional lifecycle transitions +- calling it a `Classifier` would understate responsibility and make semantic leakage into + neighboring modules more likely + +V1 source-of-truth table should follow the currently registered teammate-operational board tools +from `agent-teams-controller/src/mcpToolCatalog.js`. + +Recommended v1 classification table: +- `lifecycle` + - `task_start` + - `task_complete` + - `task_set_status` + - `review_start` + - `review_approve` + - `review_request_changes` +- `board_action` + - `task_add_comment` + - `task_attach_comment_file` + - `task_attach_file` + - `task_get` + - `task_get_comment` + - `task_link` + - `task_set_clarification` + - `task_set_owner` + - `task_unlink` + - `review_request` +- `ignore in v1 explicit feed` + - `member_briefing` + - `task_briefing` + - `task_create` + - `task_create_from_message` + - `task_list` +- `out of domain for this feature` + - `message_send` + - all `cross_team_*` + - all `process_*` + - all `kanban_*` + - `team_launch` + - `team_stop` + +Guardrail: +- add a unit test that loads the current task/review tool names from the controller source of truth + and fails if a new teammate-operational board tool appears without explicit interpreter mapping +- this prevents the runtime semantics layer from silently drifting behind controller changes + +Concrete extraction rules: +- task locator from `taskId` +- second locator from `targetId` for relationship tools +- `task_link` / `task_unlink` produce two links +- ordinary single-target board tools should emit one link with `taskArgumentSlot = 'taskId'` +- tool-derived links in v1 should have `linkKind = 'lifecycle'` or `linkKind = 'board_action'`, never `execution` +- `review_request` is `board_action`, not lifecycle +- do not copy long text fields from input/result into transcript metadata +- capture stable ids only: + - `commentId` + - `attachmentId` + - `filename` + +Per-tool payload whitelist for `BoardTaskToolActionV1`: +- `task_set_status` + - allow `input.status` +- `task_set_owner` + - allow `input.owner` +- `task_set_clarification` + - allow `input.clarification` +- `review_request` + - allow `input.reviewer` when present +- `task_link` / `task_unlink` + - allow `input.relationship` +- `task_add_comment` + - allow `resultRefs.commentId` +- `task_get_comment` + - allow `input.commentId` +- `task_attach_file` / `task_attach_comment_file` + - allow `resultRefs.attachmentId` + - allow `resultRefs.filename` + +Everything else: +- omit `input` +- omit `resultRefs` + +This whitelist must live next to the interpreter logic, not in the UI builder. +The renderer should never decide which raw tool payload fields were safe to persist. + +#### 6. Query integration point + +File: +- `src/query.ts` + +This is the safest integration point because the loop already has: +- `toolUseBlocks` +- yielded `update.message` +- normalized `tool_result` messages + +Implementation shape: + +```ts +const boardTaskObserver = new QueryBoardTaskObserver(...) + +function emitTaskAware(message: Message): Message { + return boardTaskObserver.annotateMessage(message, { + sessionId: getSessionId(), + agentId: toolUseContext.agentId, + assistantToolUses: toolUseBlocks, + }) +} + +for await (const update of toolUpdates) { + if (update.message) { + const annotatedMessage = emitTaskAware(update.message) + + yield annotatedMessage + + toolResults.push( + ...normalizeMessagesForAPI([annotatedMessage], toolUseContext.options.tools).filter( + _ => _.type === 'user', + ), + ) + } + ... +} +``` + +Important integration rule: +- do not annotate only the `getRemainingResults()` loop +- route **all transcript-visible assistant/user yields in `query.ts`** through a small shared + helper like `emitTaskAware(...)` +- that includes: + - streaming completed tool results + - remaining tool results + - synthetic missing tool-result messages on abort + - ordinary assistant conversational messages where ambient execution stamping is allowed +- specifically verify these concrete yield sites in the current file: + - `yield result.message` from `streamingToolExecutor.getCompletedResults()` + - `yield update.message` from the main `toolUpdates` loop + - emitted messages from `yieldMissingToolResultBlocks(...)` +- explicitly exclude these non-target paths from task annotation: + - `yield message` for `postCompactMessages` + - `yield { type: 'tombstone', ... }` + - tool-use summary and other non-conversational synthetic items + +Otherwise the implementation will correctly stamp board-task tool results but still miss ordinary +assistant-side execution activity. + +`annotateMessage(...)` should: +- for user `tool_result` messages: + - iterate all `tool_result` blocks inside the message + - pair each block by `tool_use_id` with the matching assistant `tool_use` + - interpret each result + - stamp `boardTaskLinks` and `boardTaskToolActions` + - apply lifecycle transitions after stamping pre-event actor context +- for ordinary conversational messages: + - if exactly one active task exists for `(sessionId, agentId)`, stamp ambient execution link + - otherwise leave unstamped + +Pairing safety rules: +- never create `boardTaskToolActions` or lifecycle transitions from a `tool_result` block unless its + `tool_use_id` resolves to a matching assistant `tool_use` +- prefer pairing in this order: + 1. direct current-turn `assistantToolUses` + 2. `sourceToolAssistantUUID` + assistant-message lookup when available + 3. otherwise treat as unpaired and skip explicit board-task annotation for that block +- if a `tool_result` block is synthetic interrupt/error recovery output, do not emit lifecycle + transitions even if the original tool name was a board-task tool +- if the paired tool result is clearly unsuccessful, emit no lifecycle transition +- missing pairing should be visible through debug diagnostics, not silently turned into guessed links + +Recommended observer helper: + +```ts +function resolveToolUseForResultBlock(params: { + toolUseId: string + assistantToolUses: ToolUseBlock[] + sourceToolAssistantUUID?: string + assistantMessages: AssistantMessage[] +}): ToolUseBlock | null { + return ( + params.assistantToolUses.find(block => block.id === params.toolUseId) ?? + findToolUseInAssistantMessage(params.assistantMessages, params.sourceToolAssistantUUID, params.toolUseId) ?? + null + ) +} +``` + +#### 7. Persistence + +Files: +- `src/utils/sessionStorage.ts` + +Concrete rule: +- do **not** recompute task metadata in `insertMessageChain(...)` +- only make sure the new optional fields are allowed by the type and survive the spread: + +```ts +const transcriptMessage: TranscriptMessage = { + ...message, + ... +} +``` + +That keeps persistence dumb and avoids late-state bugs. + +--- + +### `claude_team` - exact touchpoints + +#### 1. Keep transcript-contract parsing local to the task-activity feature + +Recommended new file: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts` + +Rationale: +- do not bloat the generic JSONL parser with feature-specific activity semantics +- keep explicit activity reading isolated from the existing session-centric parsing pipeline +- the current generic parsed-message path does not expose all raw transcript metadata needed here, + especially `teamName` / `agentName` + +Suggested API: + +```ts +class BoardTaskActivityTranscriptReader { + async readFile(filePath: string): Promise +} +``` + +`RawTaskActivityMessage` should be local to the feature and include only: +- `filePath` +- `uuid` +- `timestamp` +- `sessionId` +- `agentId` +- `isSidechain` +- `teamName` +- `agentName` +- `boardTaskLinks` +- `boardTaskToolActions` +- `sourceOrder` + +Implementation detail: +- stream JSONL line-by-line, like the existing parser +- skip entries without `uuid` +- skip entries without `boardTaskLinks` +- increment `sourceOrder` per accepted line so same-timestamp rows remain deterministic +- no need to materialize full `ParsedMessage` + +Recommended performance guard for v1: +- add a small per-file parse cache keyed by `(filePath, size, mtimeMs)` +- return cloned cached `RawTaskActivityMessage[]` when the signature matches +- dedupe concurrent reads with an in-flight map so repeated popup opens do not parse the same file twice +- prefer mtime+size invalidation over TTL-only invalidation +- keep the cache feature-local, similar in spirit to existing parse caches such as + `LeadSessionParseCache`, instead of coupling it to the legacy logs finder +- when the discovered transcript file set changes for a team, clear cache entries for paths that + disappeared from the source set + +Suggested helper file: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts` + +Suggested first-slice cache API: + +```ts +type BoardTaskActivityFileSignature = { + size: number + mtimeMs: number +} + +class BoardTaskActivityParseCache { + getIfFresh(filePath: string, signature: BoardTaskActivityFileSignature): RawTaskActivityMessage[] | null + getInFlight(filePath: string, signature: BoardTaskActivityFileSignature): Promise | null + setInFlight(filePath: string, signature: BoardTaskActivityFileSignature, promise: Promise): void + clearInFlight(filePath: string, signature: BoardTaskActivityFileSignature): void + set(filePath: string, signature: BoardTaskActivityFileSignature, rows: readonly RawTaskActivityMessage[]): void + clearForPath(filePath: string): void +} +``` + +Why this matters: +- the task popup may reopen repeatedly for the same task while the underlying JSONL files have not changed +- without an mtime-aware cache, the new explicit feed would re-parse the same lead/subagent files on every open +- this is a classic way to make a correct feature feel flaky or slow even when the domain model is sound + +#### 2. Main-side contract parsing + +Files: +- `src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts` +- `src/main/types/jsonl.ts` only if lightweight type guards help + +Recommended functions: + +```ts +export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null +export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null +``` + +Keep this contract parser feature-local and tolerant: +- unknown fields ignored +- invalid entries dropped, not fatal + +Suggested parser shape: + +```ts +export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null { + if (!value || typeof value !== 'object') return null + const row = value as Record + const ref = typeof row.ref === 'string' ? row.ref.trim() : '' + const refKind = + row.refKind === 'canonical' || row.refKind === 'display' || row.refKind === 'unknown' + ? row.refKind + : null + const canonicalId = + typeof row.canonicalId === 'string' && row.canonicalId.trim().length > 0 + ? row.canonicalId.trim() + : undefined + if (!ref || !refKind) return null + return { ref, refKind, canonicalId } +} + +export function parseBoardTaskLinks(value: unknown): BoardTaskLinkV1[] | null { + if (!Array.isArray(value)) return null + const parsed = value + .map(item => { + if (!item || typeof item !== 'object') return null + const row = item as Record + if (row.schemaVersion !== 1) return null + const task = parseBoardTaskLocator(row.task) + if (!task) return null + const linkKind = + row.linkKind === 'execution' || + row.linkKind === 'lifecycle' || + row.linkKind === 'board_action' + ? row.linkKind + : null + const relation = + row.actorContext && + typeof row.actorContext === 'object' && + ['same_task', 'other_active_task', 'idle', 'ambiguous'].includes( + String((row.actorContext as Record).relation), + ) + ? ((row.actorContext as Record).relation as + | 'same_task' + | 'other_active_task' + | 'idle' + | 'ambiguous') + : null + if (!linkKind || !relation) return null + return { + schemaVersion: 1, + task, + taskArgumentSlot: + row.taskArgumentSlot === 'taskId' || row.taskArgumentSlot === 'targetId' + ? row.taskArgumentSlot + : undefined, + toolUseId: typeof row.toolUseId === 'string' ? row.toolUseId : undefined, + linkKind, + actorContext: { + relation, + activeTask: parseBoardTaskLocator( + (row.actorContext as Record).activeTask, + ) ?? undefined, + activePhase: + (row.actorContext as Record).activePhase === 'work' || + (row.actorContext as Record).activePhase === 'review' + ? ((row.actorContext as Record).activePhase as 'work' | 'review') + : undefined, + activeExecutionSeq: + typeof (row.actorContext as Record).activeExecutionSeq === 'number' + ? ((row.actorContext as Record).activeExecutionSeq as number) + : undefined, + }, + } satisfies BoardTaskLinkV1 + }) + .filter((entry): entry is BoardTaskLinkV1 => entry !== null) + return parsed.length > 0 ? parsed : null +} + +export function parseBoardTaskToolActions(value: unknown): BoardTaskToolActionV1[] | null { + if (!Array.isArray(value)) return null + const parsed = value + .map(item => { + if (!item || typeof item !== 'object') return null + const row = item as Record + if (row.schemaVersion !== 1) return null + const toolUseId = typeof row.toolUseId === 'string' ? row.toolUseId.trim() : '' + const canonicalToolName = + typeof row.canonicalToolName === 'string' ? row.canonicalToolName.trim() : '' + if (!toolUseId || !canonicalToolName) return null + return { + schemaVersion: 1, + toolUseId, + canonicalToolName, + } satisfies BoardTaskToolActionV1 + }) + .filter((entry): entry is BoardTaskToolActionV1 => entry !== null) + return parsed.length > 0 ? parsed : null +} +``` + +Parser behavior rule: +- do not throw for malformed per-object metadata +- salvage valid siblings and continue reading +- reserve throwing for true file-level I/O or invalid JSONL framing only + +#### 3. Task-activity builder + +Files: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts` +- `src/shared/types/team.ts` + +Add to shared IPC-visible types: + +```ts +export interface BoardTaskActivityEntry { + id: string + timestamp: string + actor: { ... } + task: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + linkKind: 'execution' | 'lifecycle' | 'board_action' + actorContext: { ... } + action: { + canonicalToolName?: string + toolUseId?: string + category: ... + peerTask?: { + locator: BoardTaskLocator + taskRef?: TaskRef + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + } + relationshipPerspective?: 'outgoing' | 'incoming' | 'symmetric' + details?: { ... } + } + source: { + messageUuid: string + filePath: string + } +} +``` + +Concrete builder algorithm: + +```ts +buildEntriesForTask(rawMessage, targetTaskId) { + const matchingLinks = rawMessage.boardTaskLinks.filter(link => matchesTarget(link.task, targetTaskId)) + const actionsByToolUseId = buildActionMap(rawMessage.boardTaskToolActions ?? []) + + return matchingLinks.map(link => { + const action = link.toolUseId ? actionsByToolUseId.get(link.toolUseId) : undefined + const siblingLinks = link.toolUseId + ? rawMessage.boardTaskLinks.filter(other => other.toolUseId === link.toolUseId) + : [] + const peerLink = siblingLinks.find(other => !sameLocator(other.task, link.task)) + + return buildTaskActivityEntry(link, action, peerLink, rawMessage) + }) +} +``` + +Recommended action-map helper: + +```ts +function buildActionMap(actions: BoardTaskToolActionV1[]): Map { + const map = new Map() + for (const action of actions) { + if (map.has(action.toolUseId)) { + logDebug('[BoardTaskActivityEntryBuilder] duplicate boardTaskToolAction toolUseId', { + toolUseId: action.toolUseId, + }) + continue + } + map.set(action.toolUseId, action) + } + return map +} +``` + +Dedupe rule: +- do not use silent `last wins` +- keep the first surviving action for a `toolUseId` +- log duplicates in debug mode so broken writer-side invariants are visible during QA + +Builder simplification rule: +- if `link.linkKind === 'execution'`, do not attempt to join an action object +- `execution` rows in v1 are ambient-only and should be rendered without `BoardTaskToolActionV1` +- only `lifecycle` and `board_action` links participate in `toolUseId -> action` joins + +Suggested locator-resolution helpers: + +```ts +type ResolvedTaskHandle = + | { resolution: 'resolved' | 'deleted'; taskRef: TaskRef } + | { resolution: 'unresolved' | 'ambiguous' } + +function buildTaskLookup( + activeTasks: TeamTask[], + deletedTasks: TeamTask[], + teamName: string, +): { + byId: Map + byDisplayId: Map> +} { + const byId = new Map() + const byDisplayId = new Map< + string, + Array<{ resolution: 'resolved' | 'deleted'; taskRef: TaskRef }> + >() + + const addTask = (task: TeamTask, resolution: 'resolved' | 'deleted') => { + const taskRef: TaskRef = { + taskId: task.id, + displayId: getTaskDisplayId(task), + teamName, + } + + byId.set(task.id, { resolution, taskRef }) + + const key = taskRef.displayId.toLowerCase() + const bucket = byDisplayId.get(key) ?? [] + bucket.push({ resolution, taskRef }) + byDisplayId.set(key, bucket) + } + + for (const task of activeTasks) addTask(task, 'active') + for (const task of deletedTasks) { + if (!byId.has(task.id)) addTask(task, 'deleted') + } + + return { byId, byDisplayId } +} + +function resolveLocator( + locator: BoardTaskLocator, + lookup: { + byId: Map + byDisplayId: Map> + }, +): ResolvedTaskHandle { + if (locator.canonicalId) { + return lookup.byId.get(locator.canonicalId) ?? { resolution: 'unresolved' } + } + + if (locator.refKind === 'canonical') { + return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' } + } + + if (locator.refKind === 'display') { + const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? [] + if (candidates.length === 1) return candidates[0] + if (candidates.length > 1) return { resolution: 'ambiguous' } + return { resolution: 'unresolved' } + } + + if (looksLikeCanonicalTaskId(locator.ref)) { + return lookup.byId.get(locator.ref) ?? { resolution: 'unresolved' } + } + + const candidates = lookup.byDisplayId.get(locator.ref.toLowerCase()) ?? [] + if (candidates.length === 1) return candidates[0] + if (candidates.length > 1) return { resolution: 'ambiguous' } + return { resolution: 'unresolved' } +} +``` + +Matching rule for `getTaskActivity(teamName, taskId)`: +- target matching should primarily compare against canonical `taskId` +- if a link only has display-form identity, resolve it through the task lookup first +- do not compare raw strings only +- do not guess by display id when the lookup returns more than one candidate +- do not drop a row solely because the target resolves to `deleted` or `unresolved` + +Suggested actor-resolution helper: + +```ts +function resolveActivityActor(rawMessage: RawTaskActivityMessage): { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string +} { + if (rawMessage.agentName && rawMessage.agentName.trim().length > 0) { + return { + memberName: rawMessage.agentName.trim(), + role: rawMessage.isSidechain ? 'member' : 'lead', + sessionId: rawMessage.sessionId, + agentId: rawMessage.agentId, + } + } + return { + memberName: undefined, + role: rawMessage.isSidechain ? 'member' : 'unknown', + sessionId: rawMessage.sessionId, + agentId: rawMessage.agentId, + } +} +``` + +Actor-resolution rule: +- prefer explicit `agentName` from the raw transcript entry +- use `isSidechain` only as a fallback hint for `role` +- do not infer actor identity from task ownership or task history + +Stable ordering rule: +- sort final `BoardTaskActivityEntry[]` by `timestamp ASC` +- tie-break by `rawMessage.filePath` +- then by `rawMessage.sourceOrder ASC` +- then by `action.toolUseId ?? ''` +- then by `id` + +This keeps the feed deterministic when multiple entries share the same timestamp or come from the +same transcript message. + +#### 4. Dedicated service, not legacy finder reuse + +Files: +- `src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts` +- `src/main/services/team/taskLogs/legacy/LegacyExecutionSessionsService.ts` +- `src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts` + +Suggested `BoardTaskActivityService` dependencies: +- `TeamTranscriptSourceLocator` +- `TeamTaskReader` +- `BoardTaskActivityTranscriptReader` + +Suggested API: + +```ts +class BoardTaskActivityService { + async getTaskActivity(teamName: string, taskId: string): Promise +} +``` + +Concrete rule: +- new service reads explicit links only +- it must not call `findLogsForTask(...)` for inference +- legacy block keeps using `TeamMemberLogsFinder` +- task lookup for builder resolution should load both: + - `TeamTaskReader.getTasks(teamName)` + - `TeamTaskReader.getDeletedTasks(teamName)` +- deleted tasks are part of history resolution, not an optional nice-to-have + +Concrete discovery rule: +- do not make `BoardTaskActivityService` depend on `TeamMemberLogsFinder` +- extract a small shared locator for: + - resolving `projectDir` + - current `leadSessionId` + - `sessionIds` + - enumerating lead and subagent transcript files +- let the new explicit path depend on that lower-level discovery boundary directly + +Why: +- `TeamMemberLogsFinder` is session-centric and attribution-heavy +- the new explicit activity path does not need member-attribution heuristics +- depending on the old finder would reintroduce the mixed-responsibility boundary we are trying to remove + +Suggested transcript-source locator shape: + +```ts +type TeamTranscriptSourceContext = { + projectDir: string + leadSessionId?: string + sessionIds: string[] +} + +class TeamTranscriptSourceLocator { + async getContext(teamName: string): Promise { ... } + + async listTranscriptFiles(teamName: string): Promise { + const context = await this.getContext(teamName) + if (!context) return [] + + const files = new Set() + if (context.leadSessionId) { + files.add(path.join(context.projectDir, `${context.leadSessionId}.jsonl`)) + } + for (const sessionId of context.sessionIds) { + const dir = path.join(context.projectDir, sessionId, 'subagents') + for (const file of await safeListAgentJsonlFiles(dir)) { + files.add(path.join(dir, file)) + } + } + return [...files].sort() + } +} +``` + +`safeListAgentJsonlFiles(...)` should mirror the existing subagent-file rules: +- include `agent-*.jsonl` +- exclude `agent-acompact*` + +Recommended main-process wiring: + +```ts +// src/main/index.ts +const teamTranscriptSourceLocator = new TeamTranscriptSourceLocator() +const taskActivityTranscriptReader = new BoardTaskActivityTranscriptReader() +const taskActivityService = new BoardTaskActivityService( + teamTranscriptSourceLocator, + new TeamTaskReader(), + taskActivityTranscriptReader, +) +``` + +Then thread the service through IPC bootstrap: + +```ts +// src/main/ipc/handlers.ts +export function initializeIpcHandlers( + registry: ServiceContextRegistry, + updater: UpdaterService, + sshManager: SshConnectionManager, + teamDataService: TeamDataService, + teamProvisioningService: TeamProvisioningService, + teamMemberLogsFinder: TeamMemberLogsFinder, + memberStatsComputer: MemberStatsComputer, + teammateToolTracker: TeammateToolTracker | undefined, + branchStatusService: BranchStatusService | undefined, + taskActivityService: BoardTaskActivityService | undefined, + ... +): void { + initializeTeamHandlers( + teamDataService, + teamProvisioningService, + teamMemberLogsFinder, + memberStatsComputer, + teamBackupService, + teammateToolTracker, + branchStatusService, + taskActivityService, + ) +} +``` + +```ts +// src/main/index.ts +initializeIpcHandlers( + registry, + updater, + sshManager, + teamDataService, + teamProvisioningService, + teamMemberLogsFinder, + memberStatsComputer, + teammateToolTracker, + branchStatusService, + taskActivityService, + ... +) +``` + +Service export note: +- if `initializeIpcHandlers(...)` in `src/main/ipc/handlers.ts` continues importing service types from + `../services`, add the new service export to: + - `src/main/services/team/index.ts` + - `src/main/services/index.ts` +- if you decide to import the new service type directly in `handlers.ts`, keep that decision local and + do not mix both import styles in the same patch + +```ts +// src/main/ipc/teams.ts +let taskActivityService: BoardTaskActivityService | null = null + +export function initializeTeamHandlers( + service: TeamDataService, + provisioningService: TeamProvisioningService, + logsFinder?: TeamMemberLogsFinder, + statsComputer?: MemberStatsComputer, + backupService?: TeamBackupService, + toolTracker?: TeammateToolTracker, + branchTracker?: BranchStatusService, + activityService?: BoardTaskActivityService, +): void { + ... + taskActivityService = activityService ?? null +} +``` + +```ts +function getTaskActivityService(): BoardTaskActivityService { + if (!taskActivityService) { + throw new Error('Task activity service is not initialized') + } + return taskActivityService +} +``` + +This keeps the new explicit path as a first-class service instead of constructing it ad hoc inside +the IPC handler. + +#### 5. Implementation checkpoints before CP1 + +These checks should happen before writing feature code. + +1. Resolve the real runtime owner for `Message` / `UserMessage` / `AssistantMessage` + - `src/utils/messages.ts` imports from `../types/message.js` + - the physical source file is not obvious from the current tree walk + - do not start patching helper signatures until the actual symbol owner is confirmed + - if necessary, use editor "Go to Definition" or TypeScript resolution tooling instead of guessing + +2. Enumerate every transcript-visible yield path in `src/query.ts` + - tool result updates + - assistant conversational updates + - synthetic missing tool-result recovery + - any other user/assistant message path that lands in transcript storage + - confirm all of them route through the planned annotation helper before enabling the feature + +3. Verify split/normalize paths in `src/utils/messages.ts` + - assistant split path must not duplicate ambient execution links onto every child + - thinking-only children must not inherit task metadata + - user tool-result children must retain only the links/actions that match the child block's `tool_use_id` + +4. Verify transcript discovery assumptions in `claude_team` + - `TeamTranscriptSourceLocator` should reuse the same lead/subagent file discovery rules as the legacy path + - subagent transcript enumeration must exclude `agent-acompact*` + - the first slice should not depend on worker-thread plumbing + +If any of these checks fail, stop and correct the plan before code changes continue. + +#### 6. IPC / preload / browser fallback + +Files: +- `src/preload/constants/ipcChannels.ts` +- `src/shared/types/api.ts` +- `src/preload/index.ts` +- `src/main/ipc/teams.ts` +- `src/main/ipc/handlers.ts` +- `src/renderer/api/httpClient.ts` + +Add: + +```ts +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity' +``` + +Shared API: + +```ts +getTaskActivity: (teamName: string, taskId: string) => Promise +``` + +Main handler shape in `teams.ts`: + +```ts +async function handleGetTaskActivity( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, +): Promise> { ... } +``` + +Recommended first-slice handler: + +```ts +async function handleGetTaskActivity( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, +): Promise> { + const vTeam = validateTeamName(teamName) + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' } + } + const vTask = validateTaskId(taskId) + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' } + } + return wrapTeamHandler('getTaskActivity', () => + getTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!), + ) +} +``` + +Recommended preload addition: + +```ts +getTaskActivity: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY, + teamName, + taskId, + ) +} +``` + +Important integration detail: +- `initializeTeamHandlers(...)` should receive the new service or create/store it next to existing + `teamMemberLogsFinder` +- `registerTeamHandlers(...)` should register `TEAM_GET_TASK_ACTIVITY` +- `removeTeamHandlers(...)` should unregister it + +Concrete handler registration: + +```ts +// registerTeamHandlers(...) +ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity) +``` + +```ts +// removeTeamHandlers(...) +ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY) +``` + +Browser fallback in `HttpAPIClient` can mirror current task-log behavior: + +```ts +getTaskActivity: async () => { + console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode') + return [] +} +``` + +#### 7. UI composition + +Files: +- `src/renderer/components/team/dialogs/TaskDetailDialog.tsx` +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` +- `src/renderer/components/team/taskLogs/TaskActivitySection.tsx` +- `src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx` + +Concrete change in `TaskDetailDialog.tsx`: +- replace direct inline `MemberLogsTab` block with `TaskLogsPanel` + +Pseudo-shape: + +```tsx + +``` + +`TaskLogsPanel` should internally render: +- `TaskActivitySection` +- `ExecutionSessionsSection` + +`ExecutionSessionsSection` should be a thin wrapper around the current `MemberLogsTab` props so the +legacy block keeps its existing behavior and polling indicators. + +UI state rule: +- `TaskActivitySection` should own its own loading and empty states +- `ExecutionSessionsSection` should keep the current refreshing and online indicators +- do not reuse `ExecutionSessionsSection` polling state as the header status for the whole `Task Logs` panel +- fetch `Task Activity` and `Execution Sessions` independently so one slow path does not block the other + +Suggested panel skeleton: + +```tsx +export function TaskLogsPanel(props: { + teamName: string + task: TeamTask + taskSince?: string + allowLeadExecutionPreview?: boolean + isLeadOwnedTask?: boolean +}): React.JSX.Element { + const { teamName, task, taskSince, allowLeadExecutionPreview, isLeadOwnedTask } = props + + return ( +
+ + +
+ ) +} +``` + +Suggested `TaskActivitySection` fetch shape: + +```tsx +const [entries, setEntries] = useState(null) +const [error, setError] = useState(null) + +useEffect(() => { + let cancelled = false + setError(null) + setEntries(null) + void api.teams + .getTaskActivity(teamName, taskId) + .then(result => { + if (!cancelled) setEntries(result) + }) + .catch(err => { + if (!cancelled) setError(err instanceof Error ? err.message : String(err)) + }) + return () => { + cancelled = true + } +}, [teamName, taskId]) +``` + +#### 8. Recommended tests + +`agent_teams_orchestrator` +- interpreter unit tests for each board tool family +- reducer tests for open/close/ambiguous transitions +- observer tests for: + - single `tool_result` + - multiple `tool_result` blocks in one message + - ambient execution stamp + - duplicate `toolUseId` no-op + +`claude_team` +- transcript reader tests for additive contract parsing +- builder tests for: + - same-task execution + - external board action + - lifecycle with pre-event actor context + - `task_link` / `task_unlink` with derived `peerTask` + - display-id collision resolves to `ambiguous`, not first-match + - deleted peer task still renders a row with `resolution = 'deleted'` + - unresolved locator still renders fallback row without navigation +- UI tests for: + - empty explicit activity + legacy sessions still visible + - `Task Activity` and `Execution Sessions` separated + - deleted or unresolved peer-task rows are visibly non-primary / non-navigable + +#### 9. Runtime diagnostics + +Add lightweight counters or debug logs around the new explicit path. + +Minimum writer-side diagnostics: +- `board_task_activity.tool_result_paired` +- `board_task_activity.tool_result_unpaired` +- `board_task_activity.synthetic_tool_result_skipped` +- `board_task_activity.lifecycle_emitted` +- `board_task_activity.lifecycle_skipped_unsuccessful` +- `board_task_activity.ambient_execution_emitted` +- `board_task_activity.ambient_execution_skipped_ambiguous` + +Minimum read-side diagnostics: +- `board_task_activity.link_parse_dropped` +- `board_task_activity.action_parse_dropped` +- `board_task_activity.duplicate_action_tool_use_id` +- `board_task_activity.unresolved_locator` +- `board_task_activity.ambiguous_locator` + +Rules: +- keep diagnostics low-cardinality +- never log full comment text, review prose, or arbitrary tool payloads +- prefer counts and short identifiers over verbose blobs +- debug logging is enough for v1 if metrics plumbing would slow the rollout, but the hook points + should still be explicit in code + +--- + +## Rollout Plan + +### CP0 - contract and names are fixed + +- finalize `BoardTaskLinkV1` +- finalize `BoardTaskToolActionV1` +- finalize `toolUseId` join rules for links and actions +- finalize the tool semantics table derived from `agent-teams-controller/src/mcpToolCatalog.js` +- finalize naming across runtime contract, read model, and renderer +- add JSON schema and fixture examples + +Pre-flight verification gate before leaving CP0: +- confirm the runtime message type owner path used by `src/utils/messages.ts` +- confirm the final transcript-discovery class name is `TeamTranscriptSourceLocator` +- confirm `query.ts` annotate coverage list is complete + +### Rollout safety switches + +Keep the feature decomposed behind separate flags or equivalent runtime gates: +- `boardTaskLinksWriteEnabled` + - enables writer-side transcript stamping only +- `boardTaskActivityReadEnabled` + - enables the new `getTaskActivity(...)` read path only +- `boardTaskActivityUiEnabled` + - enables the `Task Activity` subsection in the popup only + +Recommended staged activation: +1. writer flag on in local/dev only +2. read flag on after explicit transcripts are verified +3. UI flag on after read-side QA passes + +Kill-switch rule: +- any serious mismatch in transcript stamping should be recoverable by disabling only the write flag + without removing legacy `Execution Sessions` +- any read-side performance or parsing issue should be recoverable by disabling only the read/UI flag + while keeping persisted transcripts intact +- do not make rollout depend on a single all-or-nothing switch + +Shadow validation phase: +- before exposing the new UI section broadly, run the writer + reader path in shadow mode +- in shadow mode: + - write explicit transcript metadata + - build activity entries in the background or in targeted debug sessions + - compare obvious invariants: + - task activity rows exist for fresh lifecycle events + - no duplicate action rows per `toolUseId` + - no lifecycle rows emitted from synthetic interrupt tool results + - keep the user-facing UI hidden until these checks are stable + +### CP1 - writer-side explicit links + +- add `boardTaskLinks?: BoardTaskLinkV1[]` to transcript messages +- add `boardTaskToolActions?: BoardTaskToolActionV1[]` to transcript messages where applicable +- implement runtime tool inspection +- implement actor execution state +- stamp only explicit/safe links + +### CP2 - read-side activity feed + +- parse explicit transcript task metadata in `claude_team` +- build `BoardTaskActivityEntry` +- expose `getTaskActivity(teamName, taskId)` +- keep `getLogsForTask(...)` unchanged for the legacy block + +Do not block the first slice on worker-thread support for the new feed. + +Do not route the new explicit activity query through the existing `getLogsForTask(...)` worker and +fallback path. Keep it as a separate read path in v1 so the explicit model stays isolated from the +legacy heuristic/session pipeline. + +If profiling later shows that explicit-link scanning is still expensive, add worker support as a +follow-up slice instead of mixing that concern into the first correctness rollout. + +### CP3 - UI integration + +- replace direct `MemberLogsTab` usage in task popup with a composed panel +- outer title: `Task Logs` +- `Task Activity` +- `Execution Sessions` + +### CP4 - display policy tuning + +- map semantic activity entries to renderer labels/badges +- mute noisy read actions like `task_get`, especially same-task reads +- improve labels for lifecycle and cross-task actions +- add manual QA on real team sessions + +--- + +## Definition of Done + +- Task popup shows **two clearly separated sections**: + - `Task Activity` + - `Execution Sessions` +- A task can show actions from a different actor working on another task, without mislabeling them as execution of the target task +- Review actions appear correctly in task activity +- Multi-target tools can link to multiple tasks +- Ambiguous actor state never triggers guessing +- Existing execution-session viewing still works +- Old logs remain readable +- New logs gain explicit structural task linkage +- Locator collisions never silently pick an arbitrary task +- Deleted or unresolved peer tasks do not disappear from task activity history +- `pnpm typecheck` passes in affected repos +- targeted tests pass for: + - lifecycle events + - direct board actions + - other-active-task actor actions + - review flow + - multi-target tools + - ambiguous actor state + - explicit-link-only feed behavior in v1 + - unmatched `tool_result` blocks do not create guessed links + - synthetic interrupt tool results do not create lifecycle rows + +--- + +## Top 3 Remaining Implementation Risks + +- **1. Carrier propagation drift in `agent_teams_orchestrator`** - `🎯 9 🛡️ 8 🧠 8` - roughly `180-320` lines of careful edits. + Risk: + one message path in `src/utils/messages.ts` or `src/query.ts` forgets to keep or filter `boardTaskLinks` / `boardTaskToolActions`, which creates silent gaps or duplication. + +- **2. Partial annotate coverage in `src/query.ts`** - `🎯 8 🛡️ 8 🧠 7` - roughly `120-220` lines. + Risk: + only tool-result updates go through `emitTaskAware(...)`, while other transcript-visible assistant or user yields bypass the helper and lose ambient execution links. + +- **3. Read-side overcoupling to legacy discovery** - `🎯 9 🛡️ 9 🧠 5` - roughly `80-160` lines. + Risk: + the new explicit feed accidentally reuses `TeamMemberLogsFinder` logic and reintroduces heuristic/session coupling. Keeping `TeamTranscriptSourceLocator` separate avoids this. + +--- + +## Manual QA Checklist + +- Start task A, produce normal execution logs - activity shows execution entries for A +- While on task A, comment on task B - task B shows related board action, task A does not lose execution state +- Request review on task A - task A shows board action +- Start review on task A - task A shows lifecycle review event +- Approve or request changes on task A - task A shows lifecycle completion event +- Link task A to task B - both task activity feeds reflect the relationship action appropriately +- Change owner / status / clarification on task A - task activity row renders without parsing free-text result output +- Open a historical task without explicit links - legacy execution sessions still load + +--- + +## Final Architectural Summary + +We are explicitly separating: + +- **runtime truth** - `boardTaskLinks[]` + `boardTaskToolActions[]` +- **UI activity model** - `BoardTaskActivityEntry` +- **legacy session browsing** - `Execution Sessions` + +This avoids: +- overloading one contract with UI concerns +- overloading one UI block with two different meanings +- growing the old heuristic session finder into an even larger mixed-responsibility module + +This is the cleanest path that is: +- reliable +- understandable +- scalable +- compatible with the current codebase diff --git a/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md new file mode 100644 index 00000000..35be3ab2 --- /dev/null +++ b/docs/iterations/iteration-08-exact-task-logs-reuse-existing-renderer.md @@ -0,0 +1,1768 @@ +# Iteration 08 - Exact Task Logs Reusing Existing Execution Renderer + +> Historical note +> This document captures the planned scope and architecture at iteration time. +> It is not the source of truth for the final runtime contract. + +This iteration adds a new **Exact Task Logs** subsection under task logs and intentionally reuses the existing execution-log renderer that already works well in the app. + +The goal is **not** to invent a new log UI. + +The real problem was never the renderer. The real problem was that the old task log discovery path was: +- session-centric +- heuristic-heavy +- not strict enough about what truly belongs to a task + +The new explicit board-task linkage from iteration 07 already solved the **selection** problem. +This iteration uses that explicit linkage to feed a **task-scoped transcript slice** into the existing execution renderer. + +That means: +- keep `Task Activity` as the compact, explicit summary feed +- add `Exact Task Logs` that visually looks like the current rich logs/execution cards +- keep `Execution Sessions` as a separate legacy/session-centric block + +--- + +## Decision Record + +### Top 3 options + +1. **Reuse the existing execution renderer, but feed it a new explicit task-scoped filtered message slice** - `🎯 10 🛡️ 9 🧠 6` - примерно `550-950` строк + This is the chosen direction. + +2. **Keep `Task Activity` only as summary, and add inline tool-details drawers per row** - `🎯 8 🛡️ 9 🧠 5` - примерно `350-650` строк + Simpler, but still not the same browsing experience the user already likes. + +3. **Build a new custom task log renderer from scratch** - `🎯 3 🛡️ 5 🧠 9` - примерно `900-1600` строк + Rejected. This is a bicycle. It is slower, riskier, and likely worse than the existing renderer. + +### Chosen direction + +- Keep `Task Activity` as the compact explicit summary +- Add `Exact Task Logs` +- Render `Exact Task Logs` using the same existing execution-log rendering pipeline +- Build a new explicit task-scoped message-selection layer +- Reuse renderer primitives only, not legacy session-browsing containers +- Do **not** reuse the old heuristic session-finding logic as the source of truth + +### Why this is the right direction + +- The renderer already solves: + - tool call cards + - tool-result pairing + - text output display + - expandable items + - ordering and visual hierarchy +- The existing UX is already liked by the user +- Reusing the renderer lowers design risk +- The new explicit metadata gives us a reliable source for task scoping + +The correct architecture is: +- **reuse the renderer** +- **replace the selection logic** + +Not: +- reuse the old selection logic +- or rewrite the renderer + +--- + +## Core UX Goal + +Inside the task popup: + +1. `Task Activity` + - short explicit summary rows + - compact semantic view + +2. `Exact Task Logs` + - rich task-scoped transcript rendering + - same visual style as the current logs/execution UI + - exact tools, outputs, and grouped items + +3. `Execution Sessions` + - current legacy/session-centric browser + - still useful for exploration + - no longer treated as the primary truth for task scoping + +This gives users: +- a fast summary +- exact readable logs +- a fallback exploration view + +--- + +## Important Clarification: Which Renderer We Actually Reuse + +The correct renderer to reuse is **not** `CliLogsRichView`. + +`CliLogsRichView` is for: +- stream-json CLI tails +- provisioning / live runtime logs + +It expects a different source model. + +The renderer path that matches the desired UX in task/session views is: + +- `MemberExecutionLog` +- `transformChunksToConversation(...)` +- `enhanceAIGroup(...)` +- `DisplayItemList` +- `LastOutputDisplay` + +That is the execution/session renderer family the user is referring to. + +So the plan is: +- **reuse the execution renderer path** +- **not** the CLI stream-json renderer path + +--- + +## Main Architectural Insight + +The new exact log view must reuse the old renderer **without reintroducing old selection bugs**. + +That means we cannot simply: +- ask `TeamMemberLogsFinder` for sessions +- reuse `MemberLogsTab` +- or render whole sessions again + +We also should **not** blindly render entire AI response groups from the transcript. + +Why: +- the same AI response can contain both relevant and unrelated tools +- if we render the entire unfiltered group, we leak unrelated actions back into the task view +- that would partially recreate the same problem we just solved + +So the right architecture is: + +1. Find exact task-linked source refs using explicit metadata +2. Resolve those refs into message-level anchors +3. Build a **filtered transcript slice** that contains only task-relevant messages/blocks +4. Convert that filtered slice into `EnhancedChunk[]` +5. Render with the existing execution renderer + +The renderer stays the same. +The message-selection layer becomes explicit and strict. + +--- + +## Scope + +### Goals + +- Add `Exact Task Logs` under `Task Logs` +- Reuse the current execution renderer style +- Build exact logs only from explicit task-linked transcript metadata +- Support: + - board-task tools + - lifecycle rows + - explicit board actions + - ambient execution text/output already linked to the task +- Avoid showing unrelated tools from the same session/AI response + +### Non-Goals + +- Replacing `Task Activity` +- Deleting `Execution Sessions` +- Retroactively fixing all historical logs without explicit metadata +- Reusing heuristic session overlap as primary selection +- Building a brand-new renderer + +--- + +## Key Product Rules + +### Rule 1 - `Task Activity` stays + +`Task Activity` remains the compact summary feed. + +It is still valuable because: +- it is fast to scan +- it shows actor/task relation cleanly +- it keeps the event-level summary readable + +### Rule 2 - `Exact Task Logs` is the readable drill-down + +`Exact Task Logs` is where users read the actual tool/output flow. + +It should look and feel like the existing execution/log UI. + +### Rule 3 - `Execution Sessions` remains legacy + +`Execution Sessions` still exists because: +- it is useful for broad exploration +- it has previews and session browsing +- it can still show context the exact feed intentionally omits + +But it is no longer the primary source for task scoping. + +--- + +## Naming Decisions + +### UI names + +Use: + +- outer section: `Task Logs` +- subsection 1: `Task Activity` +- subsection 2: `Exact Task Logs` +- subsection 3: `Execution Sessions` + +This naming is explicit and easy to understand: +- summary +- exact logs +- session browser + +### Service names + +Use: + +- `BoardTaskActivityRecordSource` +- `BoardTaskExactLogsService` +- `BoardTaskExactLogSummarySelector` +- `BoardTaskExactLogDetailSelector` +- `BoardTaskExactLogChunkBuilder` + +### Shared DTO names + +Use: + +- `BoardTaskExactLogSummary` +- `BoardTaskExactLogDetail` +- `BoardTaskExactLogActor` +- `BoardTaskExactLogSource` + +### Why this naming + +- `Exact Task Logs` is user-facing and immediately understandable +- `BoardTaskActivityRecordSource` is more honest than `...Service` because this layer only supplies internal records +- `BoardTaskExactLogsService` is specific enough to avoid mixing with legacy task logs +- `Summary` + `Detail` is better than a single eager `Bundle` DTO because the renderer should load heavy exact details lazily + +--- + +## Layered Design + +This slice must preserve separation of concerns. + +### 1. Explicit activity source layer + +Responsibility: +- read explicit task-linked transcript metadata +- produce internal task activity records + +Suggested main-only type: + +```ts +type BoardTaskActivityRecord = { + timestamp: string + task: { + locator: BoardTaskLocator + resolution: 'resolved' | 'deleted' | 'unresolved' | 'ambiguous' + taskId?: string + displayId?: string + } + linkKind: 'execution' | 'lifecycle' | 'board_action' + targetRole: 'subject' | 'related' + actor: { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + isSidechain: boolean + } + actorContext: { + relation: 'same_task' | 'other_active_task' | 'idle' | 'ambiguous' + activeTask?: BoardTaskLocator + activePhase?: 'work' | 'review' + activeExecutionSeq?: number + } + action?: ParsedBoardTaskToolAction + source: { + filePath: string + messageUuid: string + toolUseId?: string + sourceOrder: number + } +} +``` + +This is **main-only** and not an IPC DTO. + +Why this shape is better than `taskId: string`: + +- it preserves unresolved and deleted states +- it avoids forcing early loss of locator semantics +- it lets both summary and exact-log readers consume the same lower-level record source + +### 2. Exact-log summary selection layer + +Responsibility: +- start from explicit activity records +- build lightweight exact-log summaries +- never parse transcript messages + +This is the most important new layer in iteration 08 because it keeps initial popup load cheap and removes transcript parsing from the summary path entirely. + +### 3. Exact-log detail selection layer + +Responsibility: +- start from one summary + explicit activity records +- parse only the referenced transcript messages +- build one filtered task-scoped message slice for one requested exact detail + +### 4. Chunk-building layer + +Responsibility: +- turn the filtered message slice into `EnhancedChunk[]` +- keep the existing execution renderer happy + +### 5. UI rendering layer + +Responsibility: +- render exact bundle details with the current execution renderer +- not decide task membership + +--- + +## Why We Need an Internal Record Layer First + +It is tempting to let `BoardTaskExactLogsService` depend directly on `BoardTaskActivityEntry`. + +That would be simpler in the short term, but it is the wrong dependency direction. + +`BoardTaskActivityEntry` is a shared UI-facing DTO. +`Exact Task Logs` needs a lower-level source model. + +So the better architecture is: + +- `BoardTaskActivityRecordSource` + - main-only + - internal source of explicit task-linked facts + +- `BoardTaskActivityService` + - maps records -> `BoardTaskActivityEntry` + +- `BoardTaskExactLogsService` + - maps records -> lightweight exact-log summaries + +- `BoardTaskExactLogDetailService` + - maps one exact summary + parsed transcript -> one renderable exact detail + +This avoids coupling a new main-side service to a renderer DTO. + +This is a strong SRP / DIP move and worth doing now. + +### Critical reuse boundary + +The new exact path must **not** introduce a second competing low-level reader for board-task transcript metadata. + +That means: + +- `BoardTaskActivityTranscriptReader` remains the single owner of: + - `boardTaskLinks[]` parsing + - `boardTaskToolActions[]` parsing + - file-level metadata parse caching for explicit board-task transcript metadata +- `BoardTaskActivityRecordSource` is extracted from the current summary path and becomes the single owner of: + - transcript metadata discovery + - task lookup and target-task filtering + - resolved internal activity records +- all of: + - `BoardTaskActivityService` + - `BoardTaskExactLogsService` + - `BoardTaskExactLogDetailService` + depend on the same `BoardTaskActivityRecordSource` + +This is the desired dependency graph: + +```ts +BoardTaskActivityTranscriptReader + -> BoardTaskActivityRecordSource + -> BoardTaskActivityService + -> BoardTaskExactLogsService + -> BoardTaskExactLogDetailService +``` + +This is explicitly **not** the desired graph: + +```ts +BoardTaskActivityTranscriptReader -> BoardTaskActivityService +parseBoardTaskLinks again elsewhere -> BoardTaskExactLogsService +``` + +Why this matters: + +- summary and exact views must agree on what explicit task-linked records exist +- task-resolution behavior must not drift between two separate low-level readers +- metadata parsing bugs must be fixed once +- caches should stay shared where possible + +So iteration 08 should extract and reuse the existing explicit-record path. +It should not create another parallel JSONL-metadata reader just for exact logs. + +--- + +## Data Flow + +### End-to-end flow + +1. Renderer asks for exact task logs: + +```ts +api.teams.getTaskExactLogSummaries(teamName, taskId) +``` + +2. IPC calls: + +```ts +BoardTaskExactLogsService.getTaskExactLogSummaries(teamName, taskId) +``` + +3. Service gets: +- active + deleted tasks from `TeamTaskReader` +- activity records from `BoardTaskActivityRecordSource` + +4. Service derives exact-log summaries **from activity records only** + +5. Renderer shows exact-log summary cards first + +6. On expand, renderer asks for one exact detail: + +```ts +api.teams.getTaskExactLogDetail(teamName, taskId, exactLogId, sourceGeneration) +``` + +7. Detail service: +- reloads the matching explicit summary anchor +- derives the minimal referenced file set for that one summary +- parses only those transcript files into strict `ParsedMessage[]` +- builds one filtered bundle slice +- converts it into `EnhancedChunk[]` + +8. Renderer reuses `MemberExecutionLog` + +--- + +## New Shared DTOs + +### IPC DTOs + +```ts +type BoardTaskExactLogActor = { + memberName?: string + role: 'member' | 'lead' | 'unknown' + sessionId: string + agentId?: string + isSidechain: boolean +} + +type BoardTaskExactLogSource = { + filePath: string + messageUuid: string + toolUseId?: string + sourceOrder: number +} + +type BoardTaskExactLogSummary = + { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + linkKinds: ('execution' | 'lifecycle' | 'board_action')[] + } & ( + | { canLoadDetail: true; sourceGeneration: string } + | { canLoadDetail: false } + ) + +type BoardTaskExactLogDetail = { + id: string + chunks: EnhancedChunk[] +} +``` + +### Why summaries + lazy detail is the safer v1 design + +Repo-local finding: + +- `Execution Sessions` already uses a lazy expand-to-load-details interaction model +- `EnhancedChunk[]` is an accepted IPC shape in this app +- but returning `EnhancedChunk[]` eagerly for every exact bundle would be materially heavier than the current execution-session path + +So the safer v1 direction is: + +- initial load -> lightweight `BoardTaskExactLogSummary[]` +- expand one row -> fetch one `BoardTaskExactLogDetail` + +This keeps: + +- initial popup payload smaller +- refresh cost lower +- parity with the existing interaction model the user already likes + +### Why `canLoadDetail` is better than `hasRenderableDetail` + +Summary stage no longer parses transcript content. + +That is a feature, not a limitation: + +- it keeps summary load cheap +- it prevents summary-stage parser drift +- it avoids lying with overconfident renderability claims + +So the summary flag should be capability-oriented: + +- `canLoadDetail = true` means the app has enough explicit anchor/source information to attempt detail loading +- it does **not** guarantee that strict detail reconstruction will succeed +- if `canLoadDetail = false`, the summary must not carry a meaningless `sourceGeneration` + +If detail later fails because the transcript row is malformed or missing, returning `missing` is still correct. + +### Source-generation coherence contract + +Lazy summaries + detail introduce one real risk: + +- summaries are loaded at time `T1` +- transcript files change +- detail is requested at time `T2` +- the same `exactLogId` may now refer to a different filtered slice or to nothing at all + +So exact logs need an explicit coherence token. + +Preferred response shape: + +```ts +type BoardTaskExactLogSummariesResponse = { + items: BoardTaskExactLogSummary[] +} +``` + +Preferred detail result shape: + +```ts +type BoardTaskExactLogDetailResult = + | { status: 'ok'; detail: BoardTaskExactLogDetail } + | { status: 'stale' } + | { status: 'missing' } +``` + +Why this is better than `null`: + +- renderer can distinguish stale summary data from a genuinely missing bundle +- UI can refresh summaries automatically on `stale` +- debugging is easier than with a single ambiguous nullish path + +### Why `sourceGeneration` belongs on each summary, not on the whole response + +Earlier drafts used one response-level generation token for the whole task. +That is weaker. + +Why: + +- exact detail is loaded one bundle at a time +- one task can reference many transcript files +- one unrelated file mutation should not stale every open summary card + +So the safer contract is: + +- each `BoardTaskExactLogSummary` carries its own `sourceGeneration` +- detail validates against that per-summary generation +- the summaries response does not need a single coarse global generation token in v1 + +This narrows stale invalidation to the actual files that back one summary. + +### Why not reuse global `TeamLogSourceTracker.logSourceGeneration` directly + +Repo-local finding: + +- `TeamLogSourceTracker` already computes a broad project-level `logSourceGeneration` +- that generation changes for any tracked transcript source movement + +That pattern is useful, but it is too broad as the primary exact-log coherence token. + +If exact logs reuse the global generation directly, then: + +- an unrelated transcript file change can invalidate all open exact-log details +- exact detail requests become noisier and more frequently stale than necessary + +So exact logs should use a **narrower source generation**: + +- derive `sourceGeneration` from the exact summary source set used for one requested summary +- typically hash normalized `(filePath, size, mtimeMs)` for the referenced transcript files + +### Why `linkKinds` is an array + +One exact-log summary/detail can legitimately originate from multiple explicit links that collapse into the same rendered bundle. + +Example: +- same tool call produced both `subject` and `related` links +- same transcript message had both an execution link and a board-action link relevant to the target task + +The bundle should render once, not duplicate. + +### File-local exact-detail boundary + +Repo-local finding: + +- existing tool/result linking in `SessionParser`, `ToolExecutionBuilder`, and the execution renderer pipeline works over one provided message slice +- bundle identity already includes `filePath` +- `MemberExecutionLog` itself only consumes `EnhancedChunk[]` and a display `memberName` + +So v1 should keep a strict boundary: + +- one exact summary belongs to one transcript file +- one exact detail request parses at most that summary's referenced file set +- no cross-file hunt for a missing paired `tool_use` or `tool_result` + +This is the safer rule because cross-file pairing would immediately reintroduce guesswork and drift. + +If a future transcript shape ever truly requires cross-file pairing, that should be a separate iteration with its own invariants and tests. + +--- + +## Exact Selection Rules + +This is the most critical part of the design. + +### Principle + +Select only what is explicitly attributable to the target task. + +Never reintroduce broad session heuristics as the exact-log source. + +### Critical anti-bug rule + +The selector must work on **explicit source refs first**, and only then read transcript content. + +It must never scan a transcript file first and try to rediscover task relevance from nearby content. + +### Step 1 - Start from explicit activity records + +Only records whose resolved target task matches the requested task are eligible. + +### Step 2 - Derive exact message anchors + +Each eligible record becomes one anchor candidate. + +Suggested internal shape: + +```ts +type BoardTaskExactLogAnchor = + | { + kind: 'tool' + filePath: string + sessionId: string + toolUseId: string + sourceMessageUuid: string + } + | { + kind: 'message' + filePath: string + sessionId: string + messageUuid: string + } +``` + +### Step 3 - Collapse multiple records into stable bundles + +Deduplicate anchors aggressively: + +- same `filePath + toolUseId` -> one tool bundle +- same `filePath + messageUuid` -> one message bundle + +### Anchor precedence rule + +If both anchors exist for the same source: + +- tool anchor: `filePath + toolUseId` +- message anchor: `filePath + messageUuid` + +then the **tool anchor wins** and the message anchor must not create a second bundle for the same tool execution. + +This is required because one task-linked tool result can also carry an explicitly linked message UUID. +Without precedence, the same action can render twice: +- once as a tool bundle +- once as a message bundle + +That would be a real regression. + +This avoids duplicate rendering when: +- multiple links point to the same tool +- link/unlink emits both subject + related rows +- one activity message contains multiple links for the same target task + +### Step 4 - Build summaries from anchors only + +Summary stage must stop here. + +For each surviving anchor: +- compute stable summary identity +- aggregate `linkKinds` +- derive actor label and source metadata +- compute per-summary `sourceGeneration` +- set `canLoadDetail` conservatively + +⚠️ Summary stage must **not** parse transcript content. + +That keeps: +- popup open cheaper +- correctness easier to reason about +- stale invalidation scoped to one summary + +### Step 5 - Build filtered message slice only on detail request + +This is where the old bugs must not come back. + +#### For tool bundles + +Include only: +- the assistant `tool_use` block with the matching `toolUseId` +- the internal user `tool_result` block with the same `toolUseId` +- explicit assistant text output only when the same assistant message is itself explicitly linked to the task + +Do **not** automatically include every other tool in the same AI response. + +#### For ambient execution/message bundles + +Include only: +- the explicitly linked message itself +- optionally, paired assistant output blocks from the same message if the linked message is assistant content + +Do **not** expand to unrelated neighboring transcript messages by default. + +### Why this stricter filtering is necessary + +If we simply render the whole AI response group, we can leak: +- unrelated board tools +- unrelated read/search tools +- unrelated support actions from the same response + +That would make the task logs look rich, but wrong. + +Exact logs must be: +- rich +- but still task-scoped + +--- + +## Exact Filtering Strategy + +The filtered slice should use **synthetic filtered `ParsedMessage` copies**, not raw original messages unchanged. + +That means: +- copy the original message metadata +- keep only the relevant content blocks +- preserve `uuid`, `timestamp`, `requestId`, sidechain flags, session metadata +- drop unrelated blocks + +### Critical consistency rule for synthetic messages + +After block filtering, derived message fields must be **recomputed**, not blindly copied. + +That includes: +- `toolCalls` +- `toolResults` +- `sourceToolUseID` +- `sourceToolAssistantUUID` +- `toolUseResult` + +If we keep the original derived fields after dropping unrelated blocks, the renderer can silently reintroduce unrelated tool cards even though the filtered content looked correct. + +That is one of the highest-risk implementation mistakes in this iteration. + +### Research-backed note: what the renderer actually reads + +From the current code: + +- assistant-side tool cards are derived primarily from assistant content blocks (`tool_use`) +- internal user tool results are derived primarily from `msg.toolResults` +- `ChunkBuilder` and `SemanticStepExtractor` do **not** rely on exactly the same fields on both sides + +Implication: + +- assistant filtered messages must preserve correct assistant content blocks +- internal user filtered messages must rebuild `toolResults[]` correctly +- copying stale derived fields is especially dangerous on the internal user side +- `toolUseResult` needs explicit handling because renderer/tool-content helpers use it for richer cards + +Suggested helper: + +```ts +function filterParsedMessageForTaskAnchor(args: { + message: ParsedMessage + anchor: BoardTaskExactLogAnchor + explicitlyLinkedMessageIds: Set +}): ParsedMessage | null +``` + +Rules: + +- assistant message: + - keep `tool_use` blocks only when `block.id === anchor.toolUseId` + - keep `text` blocks only when the message UUID is explicitly linked for the same target task + - drop unrelated `tool_use` blocks + - drop unrelated thinking blocks in v1 + +- internal user message: + - keep `tool_result` blocks only when `block.tool_use_id === anchor.toolUseId` + - rebuild `toolResults[]` only for that tool + - keep `sourceToolUseID` only when it matches + - keep `sourceToolAssistantUUID` only when the paired assistant message is present in the same bundle + - keep `toolUseResult` only when it can be proven to belong to the same surviving `toolUseId` + - if that proof is missing, drop `toolUseResult` instead of risking leaked payload from another tool + +- ordinary user/system message: + - keep only if explicitly linked by `messageUuid` + +This preserves correctness and still allows the renderer to work. + +### `toolUseResult` preservation policy + +Repo-local finding: + +- `displayItemBuilder` uses `toolUseResult` while building linked tool items +- `toolContentChecks` uses `toolUseResult` to decide whether richer content exists for read/write/edit-style tools +- `ToolResultExtractor` also treats `toolUseResult` as an alternate result carrier + +So `toolUseResult` is not optional sugar. +It can materially affect what the renderer shows. + +Safe v1 rule: + +- keep `toolUseResult` only when: + - the filtered internal-user message still points to exactly one surviving `toolUseId` + - that `toolUseId` matches `sourceToolUseID` or an equivalent explicit enriched field +- otherwise: + - drop `toolUseResult` + +Why this is safer: + +- false negatives only degrade richness for one tool card +- false positives can leak payload from a different tool execution into the current exact bundle + +For exact task logs, false negative is preferable to false positive. + +### Streaming assistant dedupe rule + +Another repo-local finding: + +- `parseJsonlFile(...)` parses streaming assistant entries as separate `ParsedMessage`s +- `deduplicateByRequestId(...)` exists, but it is not automatically applied by the general renderer pipeline +- if exact logs do nothing, the same assistant response can survive more than once inside one bundle + +That can cause: + +- duplicated output rows +- duplicated tool-use blocks from intermediate streaming entries +- unstable exact bundles for the same task over time + +So the exact-log path must add an explicit dedupe step: + +- after synthetic filtering +- before chunk building +- per bundle candidate +- keep only the last surviving assistant message for a given `requestId` + +Important: + +- do not dedupe across different bundles +- do not dedupe by `requestId` before filtering, because different streaming snapshots may survive differently after block filtering + +The safe sequence is: + +1. parse strict file-local `ParsedMessage[]` +2. build one filtered synthetic bundle slice +3. dedupe assistant streaming entries by `requestId` inside that slice +4. build chunks from that deduped bundle slice + +This should be pinned with tests. + +### Strict timestamp and source-fidelity rule + +The exact-log path must not become looser than the summary path about malformed transcript rows. + +Important repo-local finding: + +- the current explicit activity reader already skips rows without a real transcript `timestamp` +- the generic `parseJsonlFile(...)` path currently falls back to `new Date()` when raw transcript `timestamp` is missing + +That fallback is acceptable for broad session utilities, but it is **not** acceptable for exact task logs. + +If exact logs silently synthesize “now” for malformed transcript rows, we get: + +- unstable ordering across reads +- bundles that appear newer than they really are +- drift between `Task Activity` and `Exact Task Logs` + +So the exact-log path must use a **strict timestamp policy**: + +- missing or malformed raw transcript timestamp -> drop the exact-log row or exact-log message +- never synthesize current time + +Preferred implementation direction: + +- add a small exact-log-specific strict parser wrapper +- optionally, only if it stays clearly isolated, extend low-level JSONL parsing with an opt-in strict mode used exclusively by exact logs + +Rejected shortcut: + +- parse with the permissive default path and try to detect synthetic timestamps later + +That shortcut is not reliable because the fallback timestamp becomes indistinguishable from a valid parsed timestamp after parsing. + +Important repo-local constraint: + +- `parseJsonlFile(...)` is used broadly across the app +- changing its default permissive behavior would create unrelated blast radius + +So the safer v1 direction is: + +- keep the global permissive parser unchanged +- add an exact-log-specific strict wrapper or opt-in exact mode +- contain the stricter behavior inside the exact-log path only + +### Classification rule for synthetic filtered messages + +The plan relies on the current `MessageClassifier` behavior: + +- filtered internal user tool-result messages are still classified into the AI path +- they are not rendered as user bubbles as long as they remain internal/meta user messages + +This is good for the chosen design, but it is a dependency that must be pinned with tests. + +If this classifier behavior changes later, exact logs can silently degrade. + +--- + +## Chunk Building Strategy + +### Chosen direction + +Reuse: + +- `ChunkBuilder.buildChunks(...)` +- `transformChunksToConversation(...)` +- `enhanceAIGroup(...)` +- `MemberExecutionLog` + +### Pre-flight checkpoint + +Before coding the bundle builder, confirm with tests that: + +- filtered internal user messages still classify into the expected AI path in `MessageClassifier` +- filtered assistant + internal user slices still produce the expected tool cards in `MemberExecutionLog` +- filtered tool-result-only bundles still render meaningfully even when no paired assistant tool-use survives +- filtered bundles with multiple assistant streaming snapshots collapse to one stable assistant row per `requestId` +- `toolUseResult`-backed richer tool cards still work when the surviving bundle truly owns that tool result +- `toolUseResult` is dropped when ownership is ambiguous + +This must be verified, not assumed. + +### Important rule + +Build chunks from the **filtered slice**, not from the entire session. + +### Bundle isolation rule + +Build chunks **per requested exact bundle detail**, not from a concatenated multi-bundle slice. + +Why: + +- `ChunkBuilder` buffers adjacent AI-category messages together +- if two anchors are concatenated before chunk building, separate exact bundles can accidentally merge into one AI chunk +- that would produce unstable visual grouping and leak unrelated context between bundles + +So the correct sequence is: + +1. derive one exact detail candidate +2. build one filtered message slice for that candidate +3. build chunks for that candidate only +4. map to one `BoardTaskExactLogDetail` + +Suggested builder: + +```ts +class BoardTaskExactLogChunkBuilder { + constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {} + + buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] { + return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true }) + } +} +``` + +### Why not pass subagents/processes in v1 + +The exact log slice is already strict and synthetic. + +Passing full process linkage into this slice creates extra coupling and raises contamination risk. + +In v1: +- pass no additional processes +- render only what is explicitly in the filtered message slice + +That is safer and easier to reason about. + +### Why no `SessionParser` as the main entrypoint + +`SessionParser` is useful for whole-session views, but it is not the ideal entrypoint here. + +For exact logs we want: +- file-local parsed messages +- no whole-session grouping assumptions +- no extra session-level work unless needed + +So the preferred path in v1 is: + +- parse raw transcript files into `ParsedMessage[]` +- then run exact-bundle selection on top + +Do not start from a full `SessionDetail` pipeline unless implementation proves it is actually simpler without correctness cost. + +--- + +## Why We Should Not Reuse `MemberLogsTab` + +`MemberLogsTab` is valuable, but it is the wrong source layer for exact logs. + +It still depends on: +- session summaries +- session overlap +- task work intervals +- preview logic +- owner-session assumptions + +That logic remains useful for `Execution Sessions`, but should not be reused as the source for exact task logs. + +Correct reuse target: +- renderer primitives + +Wrong reuse target: +- legacy session discovery + +### Renderer reuse boundary + +Reusing the existing renderer means reusing its current visual behavior too. + +That is intentional in v1: + +- exact details render through `MemberExecutionLog` +- item ordering follows that component's existing behavior +- no ongoing/session-status affordances are added +- no extra subagent/process enrichment is injected beyond what exists in the filtered chunk slice + +This keeps iteration 08 focused on the hard problem - correct task-scoped selection - instead of accidentally starting a parallel renderer redesign. + +--- + +## New Main-Side Services + +### 1. `BoardTaskActivityRecordSource` + +Responsibility: +- read transcript metadata +- resolve task-linked records +- expose internal activity records + +Potential implementation: +- extract common lower-level logic from current `BoardTaskActivityService` +- keep `BoardTaskActivityService` as record -> DTO mapper + +### 2. `BoardTaskExactLogSummarySelector` + +Responsibility: +- take activity records only +- group them by exact-log anchor +- produce lightweight exact-log summaries + +Important: +- this selector owns anchor precedence +- this selector must not parse transcript files +- this selector computes per-summary `sourceGeneration` +- computing `sourceGeneration` may stat referenced files, but it must not parse transcript content +- this selector decides `canLoadDetail` conservatively from anchor shape and record fidelity + +### 3. `BoardTaskExactLogDetailSelector` + +Responsibility: +- take one exact-log summary + strict parsed transcript messages +- produce one filtered message slice for one requested exact detail + +Important: +- this selector owns derived-field recomputation requirements for filtered messages +- this selector must not return raw original `ParsedMessage` arrays when block filtering happened + +### 4. `BoardTaskExactLogChunkBuilder` + +Responsibility: +- convert filtered message bundles into `EnhancedChunk[]` + +Important: +- one bundle in, one bundle out +- no cross-bundle chunk building + +### 5. `BoardTaskExactLogsService` + +Responsibility: +- orchestrate the exact-log summary flow +- expose IPC-facing `BoardTaskExactLogSummariesResponse` + +Important: +- this service consumes `BoardTaskActivityRecordSource` +- it must not directly parse `boardTaskLinks[]` from JSONL lines itself +- it must not parse transcript messages in the summary path +- it may read file metadata needed for per-summary `sourceGeneration` +- it should not own a second explicit-metadata parser + +### 6. `BoardTaskExactLogDetailService` + +Responsibility: +- resolve one exact bundle summary into one renderable exact detail +- expose IPC-facing `BoardTaskExactLogDetailResult` + +Important: +- this service consumes `BoardTaskActivityRecordSource` +- this service consumes `BoardTaskExactLogDetailSelector` +- this service owns strict per-bundle filtering +- this service owns per-bundle assistant `requestId` dedupe before chunk building +- this service returns `stale` or `missing` instead of guessing when a requested bundle can no longer be rendered safely + +--- + +## Proposed File Touchpoints + +### `claude_team` main + +Add: + +- `src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts` +- `src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts` + +Touch: + +- `src/main/ipc/teams.ts` +- `src/main/ipc/handlers.ts` +- `src/main/index.ts` +- `src/preload/index.ts` +- `src/preload/constants/ipcChannels.ts` +- `src/shared/types/api.ts` +- `src/shared/types/team.ts` + +### `claude_team` renderer + +Add: + +- `src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx` +- `src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx` + +Touch: + +- `src/renderer/components/team/taskLogs/TaskLogsPanel.tsx` + +### `agent_teams_orchestrator` + +No new write-side contract is required for this iteration if iteration 07 metadata is already present. + +Only touch write-side if a concrete missing field is discovered during implementation. + +That is an explicit scope guard. + +--- + +## IPC Plan + +Add: + +```ts +teams.getTaskExactLogSummaries( + teamName: string, + taskId: string +): Promise +teams.getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string +): Promise +``` + +Suggested IPC channel: + +```ts +TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries' +TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail' +``` + +These methods must be: +- independent from `getLogsForTask(...)` +- independent from the legacy worker fallback path +- explicit-metadata only in v1 +- browser-safe in the same way as other team-only methods: + - summaries -> `{ items: [] }` + - detail -> `{ status: 'missing' }` + +### Return shape rule + +The API should: + +- return lightweight summaries from the summary endpoint +- return already-built `EnhancedChunk[]` only from the detail endpoint +- never return raw messages plus renderer-side building instructions + +Why: + +- chunk building belongs to the main-side service layer +- renderer should stay simple +- this keeps exact-log selection and filtering logic out of the renderer +- this keeps the initial popup payload materially smaller + +### Ordering rule + +Returned summaries must be sorted deterministically by: + +1. explicit source timestamp +2. `filePath` +3. `sourceOrder` +4. `toolUseId` +5. `id` + +This avoids UI drift when multiple transcript rows share the same minute/second bucket. + +--- + +## Renderer Plan + +### `TaskLogsPanel` + +Target composition: + +```tsx + + + +``` + +### `ExactTaskLogsSection` + +Responsibilities: +- fetch `teams.getTaskExactLogSummaries(...)` +- load independently from `ExecutionSessionsSection` +- show loading / error / empty state +- render one card per exact log summary + +### Exact-log loading policy + +Exact logs are materially heavier than summary rows. + +So the safe v1 loading policy is: + +- load when the task popup opens and the section becomes visible +- if the section is collapsed, do not keep a blind frequent poll running +- if the task is active and the section is expanded, a slower revalidation loop is acceptable +- manual refresh is acceptable and should be easy to add + +This is better than unconditional frequent polling because exact logs require: + +- explicit record lookup +- transcript file parsing for exact detail +- synthetic message filtering +- chunk building + +Those costs are much higher than the summary feed. + +### `ExactTaskLogCard` + +Responsibilities: +- show timestamp + actor label +- show source metadata if helpful +- lazy-load detail on expand +- render the loaded detail via `MemberExecutionLog` +- keep the expand control disabled when `canLoadDetail === false` + +Example: + +```tsx +if (summary.canLoadDetail) { + const detail = await api.teams.getTaskExactLogDetail( + teamName, + taskId, + summary.id, + summary.sourceGeneration + ) + if (detail.status === 'ok') { + return + } +} +``` + +### Actor label rule + +Fix the current weak UX: + +- if `memberName` exists -> show it +- else if `isSidechain === false` -> show `lead session` +- else -> show `unknown actor` + +This is much safer and more readable than the current fallback. + +--- + +## Empty State Policy + +If there are explicit activity rows but no exact renderable summaries: + +- do **not** silently disappear +- show a clear empty state such as: + +`Exact task-scoped transcript groups are not available for these activity rows yet.` + +If no explicit activity exists: + +`No explicit task-linked logs found in transcript metadata.` + +This matters because: +- summary-only history is still useful +- users should not assume the feature is broken + +--- + +## Performance Plan + +This slice can get expensive if implemented naively. + +### Required v1 protections + +1. Parse cache by `filePath + mtimeMs + size` +2. In-flight dedupe for concurrent reads +3. Deduplicate anchors before building summaries +4. In the summary path, do not parse transcript content at all +5. In the detail path, do not parse the same file repeatedly inside one request +6. In the detail path, derive referenced file paths from explicit activity records first, then parse only that subset +7. Avoid unconditional high-frequency polling for exact logs +8. Share the explicit metadata reader/record source with the summary path instead of re-reading metadata in a second pipeline +9. Keep exact detail lazy, not eager, in v1 + +### Nice-to-have only if needed later + +- per-task result cache +- cross-service parsed transcript cache reuse + +Do not over-engineer that before profiling. + +--- + +## Consistency Rules + +### Rule 1 - Exact logs are explicit-link only + +Do not add: +- work-interval fallback +- mention matching +- owner fallback +- “close enough” neighboring tool inference + +### Rule 2 - Exact logs and summary use the same explicit source + +`Task Activity` and `Exact Task Logs` should derive from the same underlying explicit activity records, not from separate competing interpretations. + +That means: + +- same `BoardTaskActivityRecordSource` +- same explicit transcript metadata semantics +- same target-task resolution rules + +The two views may diverge in presentation. +They must not diverge in their low-level notion of “this transcript source is explicitly linked to this task”. + +### Rule 3 - Summary selector is the single source of truth for summary identity + +`exactLogId` and per-summary `sourceGeneration` must come from one place only: + +- `BoardTaskExactLogSummarySelector` + +That means: + +- `BoardTaskExactLogsService` uses it to emit summaries +- `BoardTaskExactLogDetailService` uses the same selector to rebuild summaries before loading detail +- detail service must not recompute ids with its own string concatenation rules +- detail service must not recompute generations with a different file-ordering rule + +Why this matters: + +- summary/detail drift is otherwise easy to introduce silently +- one tiny id-format change can turn every detail request into `missing` +- one tiny generation-ordering change can turn valid detail requests into false `stale` + +If a helper is extracted, it should stay below both services and be reused by both. + +### Rule 4 - Exact logs may be stricter than summary + +This is acceptable. + +Some summary rows may not yield rich exact summaries or rich exact details if: +- the row is too minimal +- the source message is malformed +- the source message is non-renderable in the existing pipeline + +That is better than rendering the wrong thing. + +### Rule 5 - Exact detail reconstruction is file-local in v1 + +Exact detail reconstruction must stay file-local. + +That means: + +- one summary anchor resolves to one `source.filePath` +- detail service only parses that summary's referenced files +- missing pair data in another transcript file is treated as absent, not searched globally + +Why this matters: + +- it matches the current execution renderer and tool-linking assumptions +- it keeps `sourceGeneration` honest +- it avoids a hidden return of broad transcript heuristics + +--- + +## Edge Cases + +### 1. Same tool call linked to two tasks + +Example: +- `task_link` +- `task_unlink` + +Behavior: +- both tasks may show the same exact tool bundle +- the bundle must render once per task, not duplicate within one task + +### 2. One transcript message contains multiple relevant links + +Behavior: +- collapse into one exact log bundle +- preserve all relevant `linkKinds` in metadata + +### 2b. One tool execution has both a tool anchor and a message anchor + +Behavior: +- render exactly one exact bundle +- the tool anchor wins +- the message anchor is absorbed into the same bundle metadata + +### 3. Same AI response contains relevant and irrelevant tools + +Behavior: +- render only the filtered relevant blocks +- do not include the whole raw AI response + +### 3b. Same assistant message contains both relevant text and unrelated tool calls + +Behavior: +- keep the explicitly linked text +- drop unrelated tool calls +- rebuild derived assistant-side tool structures from the surviving blocks only + +### 4. Lead-session row without actor name + +Behavior: +- show `lead session` +- not `unknown actor` + +### 5. Missing paired `tool_use` + +Behavior: +- if `tool_result` exists but paired assistant `tool_use` cannot be found, render what is available +- do not guess missing tool input +- do not search other transcript files for the missing pair in v1 + +### 6. Missing timestamp / malformed row + +Behavior: +- skip malformed rows +- do not synthesize “current time” + +### 7. Execution-only ambient rows + +Behavior: +- may render as exact text/output-only bundles +- no fake tool payload should be attached + +--- + +## Suggested Internal Helper Shapes + +### Bundle source model + +```ts +type BoardTaskExactLogBundleCandidate = { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + records: BoardTaskActivityRecord[] +} & ( + | { canLoadDetail: true; sourceGeneration: string } + | { canLoadDetail: false } +) + +type BoardTaskExactLogDetailCandidate = { + id: string + timestamp: string + actor: BoardTaskExactLogActor + source: BoardTaskExactLogSource + records: BoardTaskActivityRecord[] + filteredMessages: ParsedMessage[] +} +``` + +### Bundle identity rule + +Use: + +- tool bundle id: `tool:${filePath}:${toolUseId}` +- message bundle id: `message:${filePath}:${messageUuid}` + +Do not use timestamps as the primary identity. +Timestamps are for ordering, not identity. + +### Summary source-of-truth rule for actor label + +`MemberExecutionLog` only receives `chunks` plus one optional `memberName`. + +So v1 should not try to rediscover actor identity from filtered exact-detail messages. +The authoritative actor label for the exact-log card should come from the summary/record side: + +- exact summary owns the visible actor label +- exact detail rendering reuses that summary actor label +- detail reconstruction should not override it based on incidental filtered message content + +### Selector skeleton + +```ts +class BoardTaskExactLogSummarySelector { + selectSummaries(args: { + records: BoardTaskActivityRecord[] + }): BoardTaskExactLogBundleCandidate[] { + // 1. derive anchors from explicit records + // 2. apply tool-anchor-over-message precedence + // 3. dedupe anchors + // 4. compute per-summary sourceGeneration + // 5. return one candidate per summary + } +} + +class BoardTaskExactLogDetailSelector { + selectDetail(args: { + summary: BoardTaskExactLogSummary + records: BoardTaskActivityRecord[] + parsedMessagesByFile: Map + }): BoardTaskExactLogDetailCandidate | null { + // 1. rebuild the matching anchor from explicit records + // 2. parse only the files referenced by that summary + // 3. build filtered synthetic ParsedMessage[] for that one anchor + // 4. return one detail candidate or null + } +} +``` + +### Service skeleton + +```ts +class BoardTaskExactLogsService { + async getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise { + // 1. get explicit activity records + // 2. build exact summaries from records only + // 3. sort deterministically + // 4. map summary response + } +} + +class BoardTaskExactLogDetailService { + async getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise { + // 1. rebuild the matching summary from explicit records + // 2. if summary.canLoadDetail !== true -> return { status: 'missing' } + // 3. compare expectedSourceGeneration with recomputed summary.sourceGeneration + // 4. if mismatch -> return { status: 'stale' } + // 5. parse only the summary's referenced files via strict parser + // 6. build one filtered detail candidate + // 7. dedupe assistant streaming rows by requestId + // 8. build chunks + // 9. return one detail DTO or { status: 'missing' } + } +} +``` + +--- + +## Rollout Plan + +### Feature gates + +Use separate read/UI gates: + +- `CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED` +- `VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED` + +Do not reuse the iteration 07 gates directly. + +This lets us: +- validate main-side behavior first +- then enable renderer independently + +### Rollout stages + +#### Stage 1 - Main-side exact bundle service + +- build record source +- build exact summaries +- add tests +- no UI yet + +#### Stage 2 - IPC + preload + +- expose `getTaskExactLogSummaries(...)` +- expose `getTaskExactLogDetail(...)` +- add integration tests + +#### Stage 3 - Renderer section + +- add `ExactTaskLogsSection` +- wire into task popup +- keep disabled by UI flag initially + +#### Stage 4 - Manual shadow validation + +Compare: +- `Task Activity` +- `Exact Task Logs` +- `Execution Sessions` + +for several real teams and transcript shapes. + +--- + +## Testing Plan + +### Main tests + +Add focused tests for: + +1. `BoardTaskActivityRecordSource` + - explicit record extraction matches existing activity semantics + +2. Exact-log selectors + become two focused test units: + + `BoardTaskExactLogSummarySelector` + - dedupes repeated refs + - applies tool-anchor-over-message precedence + - computes stable per-summary `sourceGeneration` + - does not parse transcript content + - sets `canLoadDetail` conservatively + - omits `sourceGeneration` when `canLoadDetail === false` + + `BoardTaskExactLogDetailSelector` + - filters unrelated tools from same AI response + - keeps filtered internal-user results in the AI rendering path + - keeps paired tool_use + tool_result + - preserves explicit assistant text when linked + - rebuilds derived fields after block filtering + - keeps `toolUseResult` only for the surviving matching tool result + - dedupes assistant streaming entries by `requestId` after filtering + - never searches outside the summary's file-local source set for missing pairs + +3. `BoardTaskExactLogChunkBuilder` + - builds renderable `EnhancedChunk[]` + - never merges adjacent candidates into one cross-bundle AI chunk + - no crash on minimal bundles + +4. `BoardTaskExactLogsService` + - returns sorted summaries + - empty when feature disabled + - returns `{ items: [] }` for unknown task + - does not invoke transcript parsing in the summary path + - does not touch the exact-log strict parser or transcript parse cache in the summary path + - emits stable per-summary `sourceGeneration` values + - never emits `sourceGeneration` for non-expandable summaries + +5. `BoardTaskExactLogDetailService` + - returns `status: 'missing'` immediately for non-expandable summaries + - returns `status: 'stale'` when requested generation no longer matches + - returns `status: 'missing'` for unknown bundle + - returns `status: 'ok'` with renderable detail for valid bundle id + - does not guess missing tool ownership + - reuses the summary actor label instead of re-deriving actor identity from filtered detail messages + +### IPC tests + +- `teams.getTaskExactLogSummaries(...)` happy path +- `teams.getTaskExactLogDetail(...)` happy path +- `teams.getTaskExactLogDetail(...)` stale-generation path +- browser fallback shape +- disabled flag path +- malformed transcript path + +### Renderer tests + +- `ExactTaskLogsSection` + - loading + - error + - empty + - renders one or more exact summaries + - reloads summaries on `stale` detail response + +### Manual validation + +Use real scenarios: + +1. normal owner task with lifecycle + comments + review +2. external actor touches another task +3. `task_link` / `task_unlink` +4. lead-session rows without `agentName` +5. task with explicit summary rows but no exact renderable detail +6. summary/detail drift after transcript update + +--- + +## Definition of Done + +This iteration is done when: + +- task popup shows: + - `Task Activity` + - `Exact Task Logs` + - `Execution Sessions` +- `Exact Task Logs` visually uses the same execution-log renderer family the user already likes +- exact logs are sourced from explicit task-linked transcript selection +- exact logs do **not** depend on legacy heuristic task/session discovery +- unrelated tools from the same AI response are not leaked into the exact view +- exact-log details are lazy-loaded, not eagerly transferred for every summary row +- main-side and renderer tests pass +- old `Execution Sessions` remains intact and isolated + +--- + +## Final Decision Summary + +The best path is: + +- **reuse the existing execution renderer** +- **do not reuse the old heuristic log discovery** +- **insert a strict explicit task-scoped transcript selection layer** + +This preserves the good UX while finally making task log attribution reliable. diff --git a/docs/iterations/schemas/board-task-transcript-v1.schema.json b/docs/iterations/schemas/board-task-transcript-v1.schema.json new file mode 100644 index 00000000..d997ee5c --- /dev/null +++ b/docs/iterations/schemas/board-task-transcript-v1.schema.json @@ -0,0 +1,192 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://claude-team.local/schemas/board-task-transcript-v1.schema.json", + "title": "Board Task Transcript V1", + "type": "object", + "properties": { + "uuid": { + "type": "string", + "minLength": 1 + }, + "timestamp": { + "type": "string", + "minLength": 1 + }, + "sessionId": { + "type": "string", + "minLength": 1 + }, + "boardTaskLinks": { + "type": "array", + "items": { + "$ref": "#/$defs/boardTaskLink" + } + }, + "boardTaskToolActions": { + "type": "array", + "items": { + "$ref": "#/$defs/boardTaskToolAction" + } + } + }, + "$defs": { + "boardTaskLocator": { + "type": "object", + "required": ["ref", "refKind"], + "properties": { + "ref": { + "type": "string", + "minLength": 1 + }, + "refKind": { + "type": "string", + "enum": ["canonical", "display", "unknown"] + }, + "canonicalId": { + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false + }, + "actorContext": { + "type": "object", + "required": ["relation"], + "properties": { + "relation": { + "type": "string", + "enum": ["same_task", "other_active_task", "idle", "ambiguous"] + }, + "activeTask": { + "$ref": "#/$defs/boardTaskLocator" + }, + "activePhase": { + "type": "string", + "enum": ["work", "review"] + }, + "activeExecutionSeq": { + "type": "number" + } + }, + "allOf": [ + { + "if": { + "properties": { + "relation": { + "enum": ["same_task", "idle", "ambiguous"] + } + }, + "required": ["relation"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["activeTask"] }, + { "required": ["activePhase"] }, + { "required": ["activeExecutionSeq"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "boardTaskLink": { + "type": "object", + "required": ["schemaVersion", "task", "targetRole", "linkKind", "actorContext"], + "properties": { + "schemaVersion": { + "const": 1 + }, + "toolUseId": { + "type": "string", + "minLength": 1 + }, + "task": { + "$ref": "#/$defs/boardTaskLocator" + }, + "targetRole": { + "type": "string", + "enum": ["subject", "related"] + }, + "linkKind": { + "type": "string", + "enum": ["execution", "lifecycle", "board_action"] + }, + "taskArgumentSlot": { + "type": "string", + "enum": ["taskId", "targetId"] + }, + "actorContext": { + "$ref": "#/$defs/actorContext" + } + }, + "allOf": [ + { + "if": { + "properties": { + "linkKind": { + "const": "execution" + } + }, + "required": ["linkKind"] + }, + "then": { + "not": { + "anyOf": [ + { "required": ["taskArgumentSlot"] } + ] + } + } + } + ], + "additionalProperties": false + }, + "boardTaskToolAction": { + "type": "object", + "required": ["schemaVersion", "toolUseId", "canonicalToolName"], + "properties": { + "schemaVersion": { + "const": 1 + }, + "toolUseId": { + "type": "string", + "minLength": 1 + }, + "canonicalToolName": { + "type": "string", + "minLength": 1 + }, + "input": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["pending", "in_progress", "completed", "deleted"] + }, + "owner": { "type": ["string", "null"] }, + "clarification": { "type": ["string", "null"], "enum": ["lead", "user", null] }, + "reviewer": { "type": "string" }, + "relationship": { + "type": "string", + "enum": ["blocked-by", "blocks", "related"] + }, + "commentId": { "type": "string" } + }, + "additionalProperties": false + }, + "resultRefs": { + "type": "object", + "properties": { + "commentId": { "type": "string" }, + "attachmentId": { "type": "string" }, + "filename": { "type": "string" } + }, + "additionalProperties": false + } + }, + "additionalProperties": false + } + }, + "additionalProperties": true +} diff --git a/scripts/diagnose-task-log-stream.ts b/scripts/diagnose-task-log-stream.ts new file mode 100644 index 00000000..fc01d956 --- /dev/null +++ b/scripts/diagnose-task-log-stream.ts @@ -0,0 +1,92 @@ +import { BoardTaskLogDiagnosticsService } from '../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; + +function usage(): string { + return 'Usage: pnpm exec tsx scripts/diagnose-task-log-stream.ts [--json]'; +} + +function formatExamples( + title: string, + examples: Array<{ + timestamp: string; + toolName: string; + toolUseId?: string; + filePath: string; + messageUuid: string; + isSidechain: boolean; + agentId?: string; + }>, +): string[] { + if (examples.length === 0) { + return []; + } + + return [ + title, + ...examples.map((example) => { + const parts = [ + `- ${example.timestamp}`, + example.toolName, + `message=${example.messageUuid}`, + `file=${example.filePath}`, + `sidechain=${String(example.isSidechain)}`, + ]; + if (example.toolUseId) { + parts.push(`toolUseId=${example.toolUseId}`); + } + if (example.agentId) { + parts.push(`agentId=${example.agentId}`); + } + return parts.join(' '); + }), + ]; +} + +async function main(): Promise { + const teamName = process.argv[2]; + const taskRef = process.argv[3]; + const jsonMode = process.argv.includes('--json'); + + if (!teamName || !taskRef) { + console.error(usage()); + process.exitCode = 1; + return; + } + + const diagnosticsService = new BoardTaskLogDiagnosticsService(); + const report = await diagnosticsService.diagnose(teamName, taskRef); + + if (jsonMode) { + console.log(JSON.stringify(report, null, 2)); + return; + } + + const lines = [ + `Task log diagnostics for ${report.teamName} #${report.task.displayId}`, + `Task: ${report.task.subject}`, + `Status: ${report.task.status}${report.task.owner ? ` owner=${report.task.owner}` : ''}`, + `Transcript files: ${report.transcript.fileCount}`, + `Explicit records: total=${report.explicitRecords.total} execution=${report.explicitRecords.execution} lifecycle=${report.explicitRecords.lifecycle} boardAction=${report.explicitRecords.boardAction}`, + `Explicit participants: ${report.explicitRecords.participants.join(', ') || 'none'}`, + `Explicit tool names: ${report.explicitRecords.toolNames.join(', ') || 'none'}`, + `Interval tool results: total=${report.intervalToolResults.total} boardMcp=${report.intervalToolResults.boardMcp} worker=${report.intervalToolResults.worker.total} explicitWorker=${report.intervalToolResults.worker.explicitLinked} missingWorker=${report.intervalToolResults.worker.missingExplicit}`, + `Stream: participants=${report.stream.participants.join(', ') || 'none'} defaultFilter=${report.stream.defaultFilter} segments=${report.stream.segmentCount}`, + `Visible stream tools: ${report.stream.visibleToolNames.join(', ') || 'none'}`, + 'Diagnosis:', + ...report.diagnosis.map((line) => `- ${line}`), + ...formatExamples( + 'Missing worker tool results without explicit links:', + report.intervalToolResults.worker.examples, + ), + ...formatExamples( + 'Empty payload examples from current stream:', + report.stream.emptyPayloadExamples, + ), + ]; + + console.log(lines.join('\n')); +} + +main().catch((error) => { + console.error(String(error)); + process.exitCode = 1; +}); diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index c6b72c9f..cd33a6d4 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -90,6 +90,10 @@ import { registerWindowHandlers, removeWindowHandlers } from './window'; import type { BranchStatusService, + BoardTaskActivityService, + BoardTaskLogStreamService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, ChangeExtractorService, CliInstallerService, FileContentResolver, @@ -130,6 +134,10 @@ export function initializeIpcHandlers( teamProvisioningService: TeamProvisioningService, teamMemberLogsFinder: TeamMemberLogsFinder, memberStatsComputer: MemberStatsComputer, + boardTaskActivityService: BoardTaskActivityService, + boardTaskLogStreamService: BoardTaskLogStreamService, + boardTaskExactLogsService: BoardTaskExactLogsService, + boardTaskExactLogDetailService: BoardTaskExactLogDetailService, teammateToolTracker: TeammateToolTracker | undefined, branchStatusService: BranchStatusService | undefined, contextCallbacks: { @@ -174,7 +182,11 @@ export function initializeIpcHandlers( memberStatsComputer, teamBackupService, teammateToolTracker, - branchStatusService + branchStatusService, + boardTaskActivityService, + boardTaskLogStreamService, + boardTaskExactLogsService, + boardTaskExactLogDetailService ); initializeConfigHandlers({ onClaudeRootPathUpdated: contextCallbacks.onClaudeRootPathUpdated, diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 6ce68389..d05f41fb 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -21,10 +21,14 @@ import { TEAM_GET_CLAUDE_LOGS, TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, - TEAM_GET_MESSAGES_PAGE, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, @@ -98,15 +102,15 @@ import { buildActionModeAgentBlock, isAgentActionMode, } from '../services/team/actionModeInstructions'; +import { + buildReplaceMembersDiff, + buildReplaceMembersSummaryMessage, +} from '../services/team/memberUpdateNotifications'; import { TeamAttachmentStore } from '../services/team/TeamAttachmentStore'; import { TeamMembersMetaStore } from '../services/team/TeamMembersMetaStore'; import { TeamMetaStore } from '../services/team/TeamMetaStore'; import { buildAddMemberSpawnMessage } from '../services/team/TeamProvisioningService'; import { TeamTaskAttachmentStore } from '../services/team/TeamTaskAttachmentStore'; -import { - buildReplaceMembersDiff, - buildReplaceMembersSummaryMessage, -} from '../services/team/memberUpdateNotifications'; import { validateFromField, @@ -118,6 +122,10 @@ import { import type { BranchStatusService, + BoardTaskActivityService, + BoardTaskLogStreamService, + BoardTaskExactLogDetailService, + BoardTaskExactLogsService, MemberStatsComputer, TeamDataService, TeammateToolTracker, @@ -131,6 +139,10 @@ import type { AttachmentFileData, AttachmentMeta, AttachmentPayload, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, CreateTaskRequest, EffortLevel, GlobalTask, @@ -143,6 +155,7 @@ import type { MemberLogSummary, MemberSpawnStatusEntry, MemberSpawnStatusesSnapshot, + MessagesPage, SendMessageRequest, SendMessageResult, TaskAttachmentMeta, @@ -155,7 +168,6 @@ import type { TeamCreateRequest, TeamCreateResponse, TeamData, - MessagesPage, TeamLaunchRequest, TeamLaunchResponse, TeamMessageNotificationData, @@ -184,7 +196,7 @@ const SEEN_RATE_LIMIT_KEYS_MAX = 500; async function getDurableLeadTeammateRoster( teamName: string, leadName: string -): Promise> { +): Promise<{ name: string; role?: string }[]> { const normalize = (name: string | undefined | null): string => name?.trim().toLowerCase() ?? ''; const leadLower = normalize(leadName); const reserved = new Set(['team-lead', 'user', leadLower].filter((value) => value.length > 0)); @@ -241,7 +253,7 @@ async function getDurableLeadTeammateRoster( function buildLeadRosterContextBlock( teamName: string, leadName: string, - teammates: Array<{ name: string; role?: string }> + teammates: { name: string; role?: string }[] ): string | null { if (teammates.length === 0) return null; @@ -377,6 +389,10 @@ let memberStatsComputer: MemberStatsComputer | null = null; let teamBackupService: TeamBackupService | null = null; let teammateToolTracker: TeammateToolTracker | null = null; let branchStatusService: BranchStatusService | null = null; +let boardTaskActivityService: BoardTaskActivityService | null = null; +let boardTaskLogStreamService: BoardTaskLogStreamService | null = null; +let boardTaskExactLogsService: BoardTaskExactLogsService | null = null; +let boardTaskExactLogDetailService: BoardTaskExactLogDetailService | null = null; const attachmentStore = new TeamAttachmentStore(); const taskAttachmentStore = new TeamTaskAttachmentStore(); @@ -407,7 +423,11 @@ export function initializeTeamHandlers( statsComputer?: MemberStatsComputer, backupService?: TeamBackupService, toolTracker?: TeammateToolTracker, - branchTracker?: BranchStatusService + branchTracker?: BranchStatusService, + taskActivityService?: BoardTaskActivityService, + taskLogStreamService?: BoardTaskLogStreamService, + taskExactLogsService?: BoardTaskExactLogsService, + taskExactLogDetailService?: BoardTaskExactLogDetailService ): void { teamDataService = service; teamProvisioningService = provisioningService; @@ -416,6 +436,10 @@ export function initializeTeamHandlers( teamBackupService = backupService ?? null; teammateToolTracker = toolTracker ?? null; branchStatusService = branchTracker ?? null; + boardTaskActivityService = taskActivityService ?? null; + boardTaskLogStreamService = taskLogStreamService ?? null; + boardTaskExactLogsService = taskExactLogsService ?? null; + boardTaskExactLogDetailService = taskExactLogDetailService ?? null; } export function registerTeamHandlers(ipcMain: IpcMain): void { @@ -450,6 +474,10 @@ export function registerTeamHandlers(ipcMain: IpcMain): void { ipcMain.handle(TEAM_CREATE_CONFIG, handleCreateConfig); ipcMain.handle(TEAM_GET_MEMBER_LOGS, handleGetMemberLogs); ipcMain.handle(TEAM_GET_LOGS_FOR_TASK, handleGetLogsForTask); + ipcMain.handle(TEAM_GET_TASK_ACTIVITY, handleGetTaskActivity); + ipcMain.handle(TEAM_GET_TASK_LOG_STREAM, handleGetTaskLogStream); + ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_SUMMARIES, handleGetTaskExactLogSummaries); + ipcMain.handle(TEAM_GET_TASK_EXACT_LOG_DETAIL, handleGetTaskExactLogDetail); ipcMain.handle(TEAM_GET_MEMBER_STATS, handleGetMemberStats); ipcMain.handle(TEAM_UPDATE_CONFIG, handleUpdateConfig); ipcMain.handle(TEAM_START_TASK, handleStartTask); @@ -517,6 +545,10 @@ export function removeTeamHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler(TEAM_CREATE_CONFIG); ipcMain.removeHandler(TEAM_GET_MEMBER_LOGS); ipcMain.removeHandler(TEAM_GET_LOGS_FOR_TASK); + ipcMain.removeHandler(TEAM_GET_TASK_ACTIVITY); + ipcMain.removeHandler(TEAM_GET_TASK_LOG_STREAM); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + ipcMain.removeHandler(TEAM_GET_TASK_EXACT_LOG_DETAIL); ipcMain.removeHandler(TEAM_GET_MEMBER_STATS); ipcMain.removeHandler(TEAM_UPDATE_CONFIG); ipcMain.removeHandler(TEAM_START_TASK); @@ -579,6 +611,34 @@ function getBranchStatusService(): BranchStatusService { return branchStatusService; } +function getBoardTaskActivityService(): BoardTaskActivityService { + if (!boardTaskActivityService) { + throw new Error('Board task activity service is not initialized'); + } + return boardTaskActivityService; +} + +function getBoardTaskLogStreamService(): BoardTaskLogStreamService { + if (!boardTaskLogStreamService) { + throw new Error('Board task log stream service is not initialized'); + } + return boardTaskLogStreamService; +} + +function getBoardTaskExactLogsService(): BoardTaskExactLogsService { + if (!boardTaskExactLogsService) { + throw new Error('Board task exact logs service is not initialized'); + } + return boardTaskExactLogsService; +} + +function getBoardTaskExactLogDetailService(): BoardTaskExactLogDetailService { + if (!boardTaskExactLogDetailService) { + throw new Error('Board task exact log detail service is not initialized'); + } + return boardTaskExactLogDetailService; +} + async function wrapTeamHandler( operation: string, handler: () => Promise @@ -1371,7 +1431,7 @@ async function handlePrepareProvisioning( ): Promise> { let validatedCwd: string | undefined; let validatedProviderId: TeamLaunchRequest['providerId']; - let validatedProviderIds: Array<'anthropic' | 'codex' | 'gemini'> | undefined; + let validatedProviderIds: ('anthropic' | 'codex' | 'gemini')[] | undefined; if (cwd !== undefined) { if (typeof cwd !== 'string' || cwd.trim().length === 0) { return { success: false, error: 'cwd must be a non-empty string' }; @@ -1391,7 +1451,7 @@ async function handlePrepareProvisioning( if (!Array.isArray(providerIds)) { return { success: false, error: 'providerIds must be an array when provided' }; } - const normalized: Array<'anthropic' | 'codex' | 'gemini'> = []; + const normalized: ('anthropic' | 'codex' | 'gemini')[] = []; for (const entry of providerIds) { if (entry !== 'anthropic' && entry !== 'codex' && entry !== 'gemini') { return { success: false, error: 'providerIds entries must be anthropic, codex, or gemini' }; @@ -2440,6 +2500,94 @@ async function handleGetLogsForTask( ); } +async function handleGetTaskActivity( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('getTaskActivity', () => + getBoardTaskActivityService().getTaskActivity(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskLogStream( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('getTaskLogStream', () => + getBoardTaskLogStreamService().getTaskLogStream(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogSummaries( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + return wrapTeamHandler('getTaskExactLogSummaries', () => + getBoardTaskExactLogsService().getTaskExactLogSummaries(vTeam.value!, vTask.value!) + ); +} + +async function handleGetTaskExactLogDetail( + _event: IpcMainInvokeEvent, + teamName: unknown, + taskId: unknown, + exactLogId: unknown, + expectedSourceGeneration: unknown +): Promise> { + const vTeam = validateTeamName(teamName); + if (!vTeam.valid) { + return { success: false, error: vTeam.error ?? 'Invalid teamName' }; + } + const vTask = validateTaskId(taskId); + if (!vTask.valid) { + return { success: false, error: vTask.error ?? 'Invalid taskId' }; + } + if (typeof exactLogId !== 'string' || exactLogId.trim().length === 0) { + return { success: false, error: 'exactLogId must be a non-empty string' }; + } + if ( + typeof expectedSourceGeneration !== 'string' || + expectedSourceGeneration.trim().length === 0 + ) { + return { success: false, error: 'expectedSourceGeneration must be a non-empty string' }; + } + return wrapTeamHandler('getTaskExactLogDetail', () => + getBoardTaskExactLogDetailService().getTaskExactLogDetail( + vTeam.value!, + vTask.value!, + exactLogId.trim(), + expectedSourceGeneration.trim() + ) + ); +} + function getMemberStatsComputer(): MemberStatsComputer { if (!memberStatsComputer) { throw new Error('Member stats computer is not initialized'); diff --git a/src/main/services/team/TaskBoundaryParser.ts b/src/main/services/team/TaskBoundaryParser.ts index d3b7512d..59991c70 100644 --- a/src/main/services/team/TaskBoundaryParser.ts +++ b/src/main/services/team/TaskBoundaryParser.ts @@ -3,6 +3,11 @@ import { createReadStream } from 'fs'; import { stat } from 'fs/promises'; import * as readline from 'readline'; +import { + canonicalizeAgentTeamsToolName, + isAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; + import type { TaskBoundariesResult, TaskBoundary, @@ -31,8 +36,6 @@ interface ToolUseInfo { filePath?: string; } -const MCP_TASK_BOUNDARY_TOOLS = new Set(['task_start', 'task_complete', 'task_set_status']); - type DetectedMechanism = 'TaskUpdate' | 'mcp' | 'none'; function extractTaskId(input: Record): string { @@ -102,7 +105,7 @@ export class TaskBoundaryParser { const b = block as Record; if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); + const toolName = canonicalizeAgentTeamsToolName(rawName); const toolUseId = typeof b.id === 'string' ? b.id : ''; const input = b.input as Record | undefined; const fp = typeof input?.file_path === 'string' ? input.file_path : undefined; @@ -238,8 +241,8 @@ export class TaskBoundaryParser { if (b.type !== 'tool_use') continue; const rawName = typeof b.name === 'string' ? b.name : ''; - const toolName = rawName.replace(/^proxy_/, ''); - if (!MCP_TASK_BOUNDARY_TOOLS.has(toolName)) continue; + const toolName = canonicalizeAgentTeamsToolName(rawName); + if (!isAgentTeamsTaskBoundaryToolName(toolName)) continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/TeamMemberLogsFinder.ts b/src/main/services/team/TeamMemberLogsFinder.ts index 6b514754..6e42d880 100644 --- a/src/main/services/team/TeamMemberLogsFinder.ts +++ b/src/main/services/team/TeamMemberLogsFinder.ts @@ -10,6 +10,10 @@ import * as readline from 'readline'; import { TeamConfigReader } from './TeamConfigReader'; import { TeamInboxReader } from './TeamInboxReader'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { + canonicalizeAgentTeamsToolName, + lineHasAgentTeamsTaskBoundaryToolName, +} from './agentTeamsToolNames'; import type { MemberLogSummary, MemberSubagentLogSummary } from '@shared/types'; @@ -684,7 +688,7 @@ export class TeamMemberLogsFinder { async listAttributedSubagentFiles( teamName: string - ): Promise> { + ): Promise<{ memberName: string; sessionId: string; filePath: string; mtimeMs: number }[]> { const discovery = await this.discoverProjectSessions(teamName); if (!discovery) return []; @@ -700,12 +704,12 @@ export class TeamMemberLogsFinder { ? [currentLeadSessionId] : sessionIds; const candidates = await this.collectSubagentCandidates(projectDir, candidateSessionIds); - const results: Array<{ + const results: { memberName: string; sessionId: string; filePath: string; mtimeMs: number; - }> = []; + }[] = []; const settled = await Promise.all( candidates.map(async (candidate) => { @@ -764,12 +768,7 @@ export class TeamMemberLogsFinder { stream.destroy(); return true; } - if ( - (line.includes('"task_start"') || - line.includes('"task_complete"') || - line.includes('"task_set_status"')) && - pattern.test(line) - ) { + if (lineHasAgentTeamsTaskBoundaryToolName(line) && pattern.test(line)) { rl.close(); stream.destroy(); return true; @@ -1146,13 +1145,9 @@ export class TeamMemberLogsFinder { // Skip read-only task tools — they reference taskId but don't indicate // that this session actually WORKED on the task. Agents commonly call // task_get to check dependencies from other tasks, producing false matches. - const toolName = typeof b.name === 'string' ? b.name : ''; - if ( - toolName === 'task_get' || - toolName === 'mcp__agent-teams__task_get' || - toolName === 'TaskGet' - ) - continue; + const rawToolName = typeof b.name === 'string' ? b.name : ''; + const toolName = canonicalizeAgentTeamsToolName(rawToolName); + if (toolName === 'task_get' || toolName === 'TaskGet') continue; const input = b.input as Record | undefined; if (!input) continue; diff --git a/src/main/services/team/agentTeamsToolNames.ts b/src/main/services/team/agentTeamsToolNames.ts new file mode 100644 index 00000000..19457c80 --- /dev/null +++ b/src/main/services/team/agentTeamsToolNames.ts @@ -0,0 +1,43 @@ +const AGENT_TEAMS_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; + +const TASK_BOUNDARY_TOOL_NAMES = ['task_start', 'task_complete', 'task_set_status'] as const; +const TASK_BOUNDARY_TOOL_SET = new Set(TASK_BOUNDARY_TOOL_NAMES); + +function escapeRegex(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +const TASK_BOUNDARY_TOOL_LINE_PATTERN = new RegExp( + `"name"\\s*:\\s*"(?:${[ + ...TASK_BOUNDARY_TOOL_NAMES, + ...TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${toolName}`), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `${prefix}${toolName}`) + ), + ...AGENT_TEAMS_PREFIXES.flatMap((prefix) => + TASK_BOUNDARY_TOOL_NAMES.map((toolName) => `proxy_${prefix}${toolName}`) + ), + ] + .map(escapeRegex) + .join('|')})"` +); + +export function canonicalizeAgentTeamsToolName(rawName: string): string { + const normalized = rawName.replace(/^proxy_/, ''); + + for (const prefix of AGENT_TEAMS_PREFIXES) { + if (normalized.startsWith(prefix)) { + return normalized.slice(prefix.length); + } + } + + return normalized; +} + +export function isAgentTeamsTaskBoundaryToolName(rawName: string): boolean { + return TASK_BOUNDARY_TOOL_SET.has(canonicalizeAgentTeamsToolName(rawName)); +} + +export function lineHasAgentTeamsTaskBoundaryToolName(line: string): boolean { + return TASK_BOUNDARY_TOOL_LINE_PATTERN.test(line); +} diff --git a/src/main/services/team/index.ts b/src/main/services/team/index.ts index ddc17421..b4e954c9 100644 --- a/src/main/services/team/index.ts +++ b/src/main/services/team/index.ts @@ -1,4 +1,9 @@ export { BranchStatusService } from './BranchStatusService'; +export { BoardTaskActivityRecordSource } from './taskLogs/activity/BoardTaskActivityRecordSource'; +export { BoardTaskActivityService } from './taskLogs/activity/BoardTaskActivityService'; +export { BoardTaskExactLogsService } from './taskLogs/exact/BoardTaskExactLogsService'; +export { BoardTaskExactLogDetailService } from './taskLogs/exact/BoardTaskExactLogDetailService'; +export { BoardTaskLogStreamService } from './taskLogs/stream/BoardTaskLogStreamService'; export { CascadeGuard } from './CascadeGuard'; export { ChangeExtractorService } from './ChangeExtractorService'; export { ClaudeBinaryResolver } from './ClaudeBinaryResolver'; diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts new file mode 100644 index 00000000..c291484a --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder.ts @@ -0,0 +1,83 @@ +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; + +import type { BoardTaskActivityEntry, TeamTask } from '@shared/types'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +function cloneTaskRef(task: BoardTaskActivityRecord['task']): BoardTaskActivityEntry['task'] { + return { + locator: { ...task.locator }, + resolution: task.resolution, + ...(task.taskRef ? { taskRef: { ...task.taskRef } } : {}), + }; +} + +function cloneActorContext( + actorContext: BoardTaskActivityRecord['actorContext'] +): BoardTaskActivityEntry['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask ? { activeTask: cloneTaskRef(actorContext.activeTask) } : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function cloneAction( + action: BoardTaskActivityRecord['action'] +): BoardTaskActivityEntry['action'] | undefined { + if (!action) return undefined; + + return { + ...(action.canonicalToolName ? { canonicalToolName: action.canonicalToolName } : {}), + ...(action.toolUseId ? { toolUseId: action.toolUseId } : {}), + category: action.category, + ...(action.peerTask ? { peerTask: cloneTaskRef(action.peerTask) } : {}), + ...(action.relationshipPerspective + ? { relationshipPerspective: action.relationshipPerspective } + : {}), + ...(action.details ? { details: { ...action.details } } : {}), + }; +} + +export class BoardTaskActivityEntryBuilder { + constructor( + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityEntry[] { + return this.buildFromRecords(this.recordBuilder.buildForTask(args)); + } + + buildFromRecords(records: BoardTaskActivityRecord[]): BoardTaskActivityEntry[] { + return records.map((record) => ({ + id: record.id, + timestamp: record.timestamp, + task: cloneTaskRef(record.task), + linkKind: record.linkKind, + targetRole: record.targetRole, + actor: { + ...(record.actor.memberName ? { memberName: record.actor.memberName } : {}), + role: record.actor.role, + sessionId: record.actor.sessionId, + ...(record.actor.agentId ? { agentId: record.actor.agentId } : {}), + isSidechain: record.actor.isSidechain, + }, + actorContext: cloneActorContext(record.actorContext), + ...(record.action ? { action: cloneAction(record.action) } : {}), + source: { + messageUuid: record.source.messageUuid, + filePath: record.source.filePath, + ...(record.source.toolUseId ? { toolUseId: record.source.toolUseId } : {}), + sourceOrder: record.source.sourceOrder, + }, + })); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts new file mode 100644 index 00000000..34464243 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityParseCache.ts @@ -0,0 +1,54 @@ +interface CacheEntry { + mtimeMs: number; + size: number; + value: T; +} + +export class BoardTaskActivityParseCache { + private readonly cache = new Map>(); + private readonly inFlight = new Map>(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): T | null { + const cached = this.cache.get(filePath); + if (!cached) return null; + if (cached.mtimeMs !== mtimeMs || cached.size !== size) { + this.cache.delete(filePath); + return null; + } + return cached.value; + } + + getInFlight(filePath: string): Promise | null { + return this.inFlight.get(filePath) ?? null; + } + + setInFlight(filePath: string, promise: Promise): void { + this.inFlight.set(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.inFlight.delete(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: T): void { + this.cache.set(filePath, { mtimeMs, size, value }); + } + + clearForPath(filePath: string): void { + this.cache.delete(filePath); + this.inFlight.delete(filePath); + } + + retainOnly(filePaths: Set): void { + for (const filePath of this.cache.keys()) { + if (!filePaths.has(filePath)) { + this.cache.delete(filePath); + } + } + for (const filePath of this.inFlight.keys()) { + if (!filePaths.has(filePath)) { + this.inFlight.delete(filePath); + } + } + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts new file mode 100644 index 00000000..cf2900b8 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecord.ts @@ -0,0 +1,25 @@ +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityActorContext, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskActivityTaskRef, +} from '@shared/types'; + +export interface BoardTaskActivityRecord { + id: string; + timestamp: string; + task: BoardTaskActivityTaskRef; + linkKind: BoardTaskActivityLinkKind; + targetRole: BoardTaskActivityTargetRole; + actor: BoardTaskActivityActor; + actorContext: BoardTaskActivityActorContext; + action?: BoardTaskActivityAction; + source: { + messageUuid: string; + filePath: string; + toolUseId?: string; + sourceOrder: number; + }; +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts new file mode 100644 index 00000000..3e20e820 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -0,0 +1,382 @@ +import { createLogger } from '@shared/utils/logger'; +import { getTaskDisplayId } from '@shared/utils/taskIdentity'; + +import type { + BoardTaskActivityAction, + BoardTaskActivityActor, + BoardTaskActivityCategory, + BoardTaskActivityTaskRef, + BoardTaskLocator, + TaskRef, + TeamTask, +} from '@shared/types'; +import type { RawTaskActivityMessage } from './BoardTaskActivityTranscriptReader'; +import type { + ParsedBoardTaskLink, + ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +interface TaskLookup { + byId: Map; + byDisplayId: Map; +} + +const logger = createLogger('Service:BoardTaskActivityRecordBuilder'); + +const CANONICAL_TASK_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function buildTaskRef(teamName: string, task: TeamTask): TaskRef { + return { + taskId: task.id, + displayId: getTaskDisplayId(task), + teamName, + }; +} + +function normalizeDisplayRef(value: string): string { + return value.trim().toLowerCase(); +} + +function looksLikeCanonicalTaskId(value: string): boolean { + return CANONICAL_TASK_ID_PATTERN.test(value.trim()); +} + +function buildTaskLookup(tasks: TeamTask[]): TaskLookup { + const byId = new Map(); + const byDisplayId = new Map(); + + for (const task of tasks) { + byId.set(task.id, task); + const displayId = normalizeDisplayRef(getTaskDisplayId(task)); + const list = byDisplayId.get(displayId) ?? []; + list.push(task); + byDisplayId.set(displayId, list); + } + + return { byId, byDisplayId }; +} + +function resolveLocatorToTaskRef( + teamName: string, + locator: BoardTaskLocator, + lookup: TaskLookup +): BoardTaskActivityTaskRef { + const canonicalCandidate = + (locator.canonicalId && lookup.byId.get(locator.canonicalId)) || + (locator.refKind === 'canonical' ? lookup.byId.get(locator.ref) : undefined) || + (locator.refKind === 'unknown' && looksLikeCanonicalTaskId(locator.ref) + ? lookup.byId.get(locator.ref) + : undefined); + + if (canonicalCandidate) { + return { + locator, + resolution: canonicalCandidate.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, canonicalCandidate), + }; + } + + const displayCandidates = lookup.byDisplayId.get(normalizeDisplayRef(locator.ref)) ?? []; + if (displayCandidates.length === 1) { + const task = displayCandidates[0]; + return { + locator, + resolution: task.status === 'deleted' ? 'deleted' : 'resolved', + taskRef: buildTaskRef(teamName, task), + }; + } + + if (displayCandidates.length > 1) { + noteReadDiagnostic('ambiguous_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'ambiguous', + }; + } + + noteReadDiagnostic('unresolved_locator', { refKind: locator.refKind }); + return { + locator, + resolution: 'unresolved', + }; +} + +function locatorCouldMatchTask( + locator: BoardTaskLocator, + targetTask: TeamTask, + lookup: TaskLookup +): boolean { + if (locator.canonicalId === targetTask.id) return true; + if (locator.refKind === 'canonical' && locator.ref === targetTask.id) return true; + + const targetDisplayId = getTaskDisplayId(targetTask); + const normalizedLocatorRef = normalizeDisplayRef(locator.ref); + const normalizedTargetDisplayId = normalizeDisplayRef(targetDisplayId); + if (normalizedLocatorRef !== normalizedTargetDisplayId) return false; + + const candidates = lookup.byDisplayId.get(normalizedTargetDisplayId) ?? []; + if (candidates.length === 0) return false; + return candidates.some((candidate) => candidate.id === targetTask.id); +} + +function buildActionMap( + actions: ParsedBoardTaskToolAction[] +): Map { + const actionMap = new Map(); + for (const action of actions) { + if (actionMap.has(action.toolUseId)) { + noteReadDiagnostic('duplicate_action_tool_use_id', { toolUseId: action.toolUseId }); + continue; + } + actionMap.set(action.toolUseId, action); + } + return actionMap; +} + +function buildActionCategory(action: ParsedBoardTaskToolAction): BoardTaskActivityCategory { + switch (action.canonicalToolName) { + case 'task_start': + case 'task_complete': + case 'task_set_status': + return 'status'; + case 'review_start': + case 'review_request': + case 'review_approve': + case 'review_request_changes': + return 'review'; + case 'task_add_comment': + case 'task_get_comment': + return 'comment'; + case 'task_set_owner': + return 'assignment'; + case 'task_get': + return 'read'; + case 'task_attach_file': + case 'task_attach_comment_file': + return 'attachment'; + case 'task_link': + case 'task_unlink': + return 'relationship'; + case 'task_set_clarification': + return 'clarification'; + default: + return 'other'; + } +} + +function buildActionDetails( + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['details'] | undefined { + const details = { + ...(action.input?.status ? { status: action.input.status } : {}), + ...(action.input && 'owner' in action.input ? { owner: action.input.owner } : {}), + ...(action.input && 'clarification' in action.input + ? { clarification: action.input.clarification } + : {}), + ...(action.input?.reviewer ? { reviewer: action.input.reviewer } : {}), + ...(action.input?.relationship ? { relationship: action.input.relationship } : {}), + ...(action.input?.commentId ? { commentId: action.input.commentId } : {}), + ...(action.resultRefs?.commentId ? { commentId: action.resultRefs.commentId } : {}), + ...(action.resultRefs?.attachmentId ? { attachmentId: action.resultRefs.attachmentId } : {}), + ...(action.resultRefs?.filename ? { filename: action.resultRefs.filename } : {}), + }; + + return Object.keys(details).length > 0 ? details : undefined; +} + +function buildRelationshipPerspective( + link: ParsedBoardTaskLink, + action: ParsedBoardTaskToolAction +): BoardTaskActivityAction['relationshipPerspective'] | undefined { + const relationship = action.input?.relationship; + if (!relationship) { + return undefined; + } + if (relationship === 'related') { + return 'symmetric'; + } + if (relationship === 'blocked-by') { + return link.targetRole === 'subject' ? 'incoming' : 'outgoing'; + } + if (relationship === 'blocks') { + return link.targetRole === 'subject' ? 'outgoing' : 'incoming'; + } + return undefined; +} + +function buildAction(args: { + action: ParsedBoardTaskToolAction | undefined; + link: ParsedBoardTaskLink; + peerTask?: BoardTaskActivityTaskRef; +}): BoardTaskActivityAction | undefined { + const { action, link, peerTask } = args; + if (!action) return undefined; + const category = buildActionCategory(action); + const details = buildActionDetails(action); + const relationshipPerspective = + category === 'relationship' ? buildRelationshipPerspective(link, action) : undefined; + + return { + canonicalToolName: action.canonicalToolName, + toolUseId: action.toolUseId, + category, + ...(details ? { details } : {}), + ...(category === 'relationship' && peerTask ? { peerTask } : {}), + ...(relationshipPerspective ? { relationshipPerspective } : {}), + }; +} + +function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivityActor { + const memberName = + typeof message.agentName === 'string' && message.agentName.trim().length > 0 + ? message.agentName.trim() + : undefined; + + return { + ...(memberName ? { memberName } : {}), + role: memberName + ? message.isSidechain + ? 'member' + : 'lead' + : message.isSidechain + ? 'member' + : 'unknown', + sessionId: message.sessionId, + ...(message.agentId ? { agentId: message.agentId } : {}), + isSidechain: message.isSidechain, + }; +} + +function resolvePeerTask( + teamName: string, + currentLink: ParsedBoardTaskLink, + allLinks: ParsedBoardTaskLink[], + targetTask: TeamTask, + lookup: TaskLookup +): BoardTaskActivityTaskRef | undefined { + for (const link of allLinks) { + if (link === currentLink) continue; + if (link.toolUseId !== currentLink.toolUseId) continue; + if (locatorCouldMatchTask(link.task, targetTask, lookup)) continue; + return resolveLocatorToTaskRef(teamName, link.task, lookup); + } + return undefined; +} + +function buildActorContext( + teamName: string, + actorContext: ParsedBoardTaskLink['actorContext'], + lookup: TaskLookup +): BoardTaskActivityRecord['actorContext'] { + return { + relation: actorContext.relation, + ...(actorContext.activeTask + ? { activeTask: resolveLocatorToTaskRef(teamName, actorContext.activeTask, lookup) } + : {}), + ...(actorContext.activePhase ? { activePhase: actorContext.activePhase } : {}), + ...(actorContext.activeExecutionSeq + ? { activeExecutionSeq: actorContext.activeExecutionSeq } + : {}), + }; +} + +function compareRecords(left: BoardTaskActivityRecord, right: BoardTaskActivityRecord): 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.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskActivityRecordBuilder { + buildForTask(args: { + teamName: string; + targetTask: TeamTask; + tasks: TeamTask[]; + messages: RawTaskActivityMessage[]; + }): BoardTaskActivityRecord[] { + const lookup = buildTaskLookup(args.tasks); + const records: BoardTaskActivityRecord[] = []; + const seenIds = new Set(); + + for (const message of args.messages) { + const actionMap = buildActionMap(message.boardTaskToolActions); + + for (const link of message.boardTaskLinks) { + const resolvedTask = resolveLocatorToTaskRef(args.teamName, link.task, lookup); + if ( + resolvedTask.taskRef?.taskId !== args.targetTask.id && + !locatorCouldMatchTask(link.task, args.targetTask, lookup) + ) { + continue; + } + + const action = + link.linkKind === 'execution' || !link.toolUseId + ? undefined + : actionMap.get(link.toolUseId); + const peerTask = resolvePeerTask( + args.teamName, + link, + message.boardTaskLinks, + args.targetTask, + lookup + ); + const record: BoardTaskActivityRecord = { + id: [ + message.uuid, + link.toolUseId ?? 'ambient', + link.task.ref, + link.targetRole, + link.linkKind, + ].join(':'), + timestamp: message.timestamp, + task: resolvedTask, + linkKind: link.linkKind, + targetRole: link.targetRole, + actor: resolveActivityActor(message), + actorContext: buildActorContext(args.teamName, link.actorContext, lookup), + ...(action ? { action: buildAction({ action, link, peerTask }) } : {}), + source: { + messageUuid: message.uuid, + filePath: message.filePath, + ...(link.toolUseId ? { toolUseId: link.toolUseId } : {}), + sourceOrder: message.sourceOrder, + }, + }; + + if (seenIds.has(record.id)) { + continue; + } + seenIds.add(record.id); + records.push(record); + } + } + + return records.sort(compareRecords); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts new file mode 100644 index 00000000..d981a08a --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource.ts @@ -0,0 +1,37 @@ +import { TeamTaskReader } from '../../TeamTaskReader'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; +import { BoardTaskActivityRecordBuilder } from './BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from './BoardTaskActivityTranscriptReader'; + +import type { BoardTaskActivityRecord } from './BoardTaskActivityRecord'; + +export class BoardTaskActivityRecordSource { + constructor( + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), + private readonly recordBuilder: BoardTaskActivityRecordBuilder = new BoardTaskActivityRecordBuilder() + ) {} + + async getTaskRecords(teamName: string, taskId: string): Promise { + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const targetTask = tasks.find((task) => task.id === taskId); + if (!targetTask || transcriptFiles.length === 0) { + return []; + } + + const messages = await this.transcriptReader.readFiles(transcriptFiles); + return this.recordBuilder.buildForTask({ + teamName, + targetTask, + tasks, + messages, + }); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts new file mode 100644 index 00000000..47f6b5d9 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityService.ts @@ -0,0 +1,21 @@ +import { BoardTaskActivityEntryBuilder } from './BoardTaskActivityEntryBuilder'; +import { BoardTaskActivityRecordSource } from './BoardTaskActivityRecordSource'; +import { isBoardTaskActivityReadEnabled } from './featureGates'; + +import type { BoardTaskActivityEntry } from '@shared/types'; + +export class BoardTaskActivityService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly entryBuilder: BoardTaskActivityEntryBuilder = new BoardTaskActivityEntryBuilder() + ) {} + + async getTaskActivity(teamName: string, taskId: string): Promise { + if (!isBoardTaskActivityReadEnabled()) { + return []; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + return this.entryBuilder.buildFromRecords(records); + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts new file mode 100644 index 00000000..89006645 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { yieldToEventLoop } from '@main/utils/asyncYield'; + +import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache'; +import { + parseBoardTaskLinks, + parseBoardTaskToolActions, + type ParsedBoardTaskLink, + type ParsedBoardTaskToolAction, +} from '../contract/BoardTaskTranscriptContract'; + +const logger = createLogger('Service:BoardTaskActivityTranscriptReader'); + +export interface RawTaskActivityMessage { + filePath: string; + uuid: string; + timestamp: string; + sessionId: string; + agentId?: string; + agentName?: string; + isSidechain: boolean; + boardTaskLinks: ParsedBoardTaskLink[]; + boardTaskToolActions: ParsedBoardTaskToolAction[]; + sourceOrder: number; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +export class BoardTaskActivityTranscriptReader { + private readonly cache = new BoardTaskActivityParseCache(); + + async readFiles(filePaths: string[]): Promise { + const uniqueFilePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniqueFilePaths)); + + const parsedFiles = await Promise.all( + uniqueFilePaths.map((filePath) => this.readFile(filePath)) + ); + return parsedFiles.flat(); + } + + private async readFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.parseFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable task-activity transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async parseFile(filePath: string): Promise { + const results: RawTaskActivityMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let sourceOrder = 0; + for await (const line of rl) { + if (!line.trim()) continue; + + try { + const parsed = JSON.parse(line) as unknown; + const record = asRecord(parsed); + if (!record) continue; + + const uuid = typeof record.uuid === 'string' ? record.uuid : ''; + const sessionId = typeof record.sessionId === 'string' ? record.sessionId : ''; + const timestamp = typeof record.timestamp === 'string' ? record.timestamp : ''; + if (!uuid || !sessionId || !timestamp) continue; + + const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks); + if (boardTaskLinks.length === 0) continue; + + sourceOrder += 1; + results.push({ + filePath, + uuid, + timestamp, + sessionId, + agentId: typeof record.agentId === 'string' ? record.agentId : undefined, + agentName: typeof record.agentName === 'string' ? record.agentName : undefined, + isSidechain: record.isSidechain === true, + boardTaskLinks, + boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions), + sourceOrder, + }); + } catch (error) { + logger.debug(`Skipping malformed task-activity line in ${filePath}: ${String(error)}`); + } + + if (sourceOrder > 0 && sourceOrder % 250 === 0) { + await yieldToEventLoop(); + } + } + return results; + } +} diff --git a/src/main/services/team/taskLogs/activity/featureGates.ts b/src/main/services/team/taskLogs/activity/featureGates.ts new file mode 100644 index 00000000..c8842d10 --- /dev/null +++ b/src/main/services/team/taskLogs/activity/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskActivityReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_ACTIVITY_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts new file mode 100644 index 00000000..425517ad --- /dev/null +++ b/src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract.ts @@ -0,0 +1,308 @@ +import { createLogger } from '@shared/utils/logger'; +import type { + BoardTaskActivityLinkKind, + BoardTaskActivityPhase, + BoardTaskActivityTargetRole, + BoardTaskActorRelation, + BoardTaskLocator, +} from '@shared/types'; + +const logger = createLogger('Service:BoardTaskTranscriptContract'); + +export interface ParsedBoardTaskActorContext { + relation: BoardTaskActorRelation; + activeTask?: BoardTaskLocator; + activePhase?: BoardTaskActivityPhase; + activeExecutionSeq?: number; +} + +export interface ParsedBoardTaskLink { + schemaVersion: 1; + toolUseId?: string; + task: BoardTaskLocator; + targetRole: BoardTaskActivityTargetRole; + linkKind: BoardTaskActivityLinkKind; + taskArgumentSlot?: 'taskId' | 'targetId'; + actorContext: ParsedBoardTaskActorContext; +} + +export interface ParsedBoardTaskToolAction { + schemaVersion: 1; + toolUseId: string; + canonicalToolName: string; + input?: { + status?: 'pending' | 'in_progress' | 'completed' | 'deleted'; + owner?: string | null; + clarification?: 'lead' | 'user' | null; + reviewer?: string; + relationship?: 'blocked-by' | 'blocks' | 'related'; + commentId?: string; + }; + resultRefs?: { + commentId?: string; + attachmentId?: string; + filename?: string; + }; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function asNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function parseNullableOwner(value: unknown): string | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'clear' || normalized === 'none') { + return null; + } + return normalized; +} + +function parseStatus( + value: unknown +): 'pending' | 'in_progress' | 'completed' | 'deleted' | undefined { + const normalized = asNonEmptyString(value); + if ( + normalized === 'pending' || + normalized === 'in_progress' || + normalized === 'completed' || + normalized === 'deleted' + ) { + return normalized; + } + return undefined; +} + +function parseRelationship(value: unknown): 'blocked-by' | 'blocks' | 'related' | undefined { + const normalized = asNonEmptyString(value); + if (normalized === 'blocked-by' || normalized === 'blocks' || normalized === 'related') { + return normalized; + } + return undefined; +} + +function parseClarification(value: unknown): 'lead' | 'user' | null | undefined { + if (value === null) return null; + const normalized = asNonEmptyString(value); + if (!normalized) return undefined; + if (normalized === 'lead' || normalized === 'user') { + return normalized; + } + if (normalized === 'clear') { + return null; + } + return undefined; +} + +function noteReadDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_activity.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function parseSchemaVersion(record: Record): 1 | null { + if (record.schemaVersion === 1) { + return 1; + } + if (record.version === 1) { + return 1; + } + return null; +} + +export function parseBoardTaskLocator(value: unknown): BoardTaskLocator | null { + const record = asRecord(value); + if (!record) return null; + + const ref = asNonEmptyString(record.ref); + const refKind = asNonEmptyString(record.refKind); + if (!ref || (refKind !== 'canonical' && refKind !== 'display' && refKind !== 'unknown')) { + return null; + } + + const canonicalId = asNonEmptyString(record.canonicalId); + return { + ref, + refKind, + ...(canonicalId ? { canonicalId } : {}), + }; +} + +function parseActorContext(value: unknown): ParsedBoardTaskActorContext | null { + const record = asRecord(value); + if (!record) return null; + + const relation = asNonEmptyString(record.relation); + if ( + relation !== 'same_task' && + relation !== 'other_active_task' && + relation !== 'idle' && + relation !== 'ambiguous' + ) { + return null; + } + + const activeTask = parseBoardTaskLocator(record.activeTask); + const activePhase = asNonEmptyString(record.activePhase); + const activeExecutionSeq = + typeof record.activeExecutionSeq === 'number' && Number.isFinite(record.activeExecutionSeq) + ? record.activeExecutionSeq + : undefined; + + if (relation !== 'other_active_task') { + return { relation }; + } + + return { + relation, + ...(activeTask ? { activeTask } : {}), + ...(activePhase === 'work' || activePhase === 'review' ? { activePhase } : {}), + ...(activeExecutionSeq ? { activeExecutionSeq } : {}), + }; +} + +export function parseBoardTaskLinks(value: unknown): ParsedBoardTaskLink[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskLink[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('link_parse_dropped', { reason: 'not_object' }); + continue; + } + + const schemaVersion = parseSchemaVersion(record); + if (schemaVersion !== 1) { + noteReadDiagnostic('link_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const task = parseBoardTaskLocator(record.task); + const targetRole = asNonEmptyString(record.targetRole); + const linkKind = asNonEmptyString(record.linkKind); + const actorContext = parseActorContext(record.actorContext); + const rawTaskArgumentSlot = asNonEmptyString(record.taskArgumentSlot); + const taskArgumentSlot = + rawTaskArgumentSlot === 'taskId' || rawTaskArgumentSlot === 'targetId' + ? rawTaskArgumentSlot + : undefined; + const toolUseId = asNonEmptyString(record.toolUseId); + + if (!task) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_task' }); + continue; + } + if (!actorContext) { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_actor_context' }); + continue; + } + if (targetRole !== 'subject' && targetRole !== 'related') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_target_role' }); + continue; + } + if (linkKind !== 'execution' && linkKind !== 'lifecycle' && linkKind !== 'board_action') { + noteReadDiagnostic('link_parse_dropped', { reason: 'invalid_link_kind' }); + continue; + } + const sanitizedToolUseId = toolUseId; + const sanitizedTaskArgumentSlot = linkKind === 'execution' ? undefined : taskArgumentSlot; + + parsed.push({ + schemaVersion: 1, + task, + targetRole, + linkKind, + actorContext, + ...(sanitizedToolUseId ? { toolUseId: sanitizedToolUseId } : {}), + ...(sanitizedTaskArgumentSlot ? { taskArgumentSlot: sanitizedTaskArgumentSlot } : {}), + }); + } + + return parsed; +} + +export function parseBoardTaskToolActions(value: unknown): ParsedBoardTaskToolAction[] { + if (!Array.isArray(value)) return []; + + const parsed: ParsedBoardTaskToolAction[] = []; + for (const item of value) { + const record = asRecord(item); + if (!record) { + noteReadDiagnostic('action_parse_dropped', { reason: 'not_object' }); + continue; + } + if (parseSchemaVersion(record) !== 1) { + noteReadDiagnostic('action_parse_dropped', { reason: 'unsupported_version' }); + continue; + } + + const toolUseId = asNonEmptyString(record.toolUseId); + const canonicalToolName = asNonEmptyString(record.canonicalToolName); + if (!toolUseId || !canonicalToolName) { + noteReadDiagnostic('action_parse_dropped', { reason: 'missing_identity' }); + continue; + } + + const inputRecord = asRecord(record.input); + const resultRefsRecord = asRecord(record.resultRefs); + + parsed.push({ + schemaVersion: 1, + toolUseId, + canonicalToolName, + ...(inputRecord + ? { + input: { + ...(parseStatus(inputRecord.status) !== undefined + ? { status: parseStatus(inputRecord.status) } + : {}), + ...(parseNullableOwner(inputRecord.owner) !== undefined + ? { owner: parseNullableOwner(inputRecord.owner) } + : {}), + ...(parseClarification(inputRecord.clarification) !== undefined + ? { clarification: parseClarification(inputRecord.clarification) } + : {}), + ...(asNonEmptyString(inputRecord.reviewer) + ? { reviewer: asNonEmptyString(inputRecord.reviewer) } + : {}), + ...(parseRelationship(inputRecord.relationship) !== undefined + ? { relationship: parseRelationship(inputRecord.relationship) } + : {}), + ...(asNonEmptyString(inputRecord.commentId) + ? { commentId: asNonEmptyString(inputRecord.commentId) } + : {}), + }, + } + : {}), + ...(resultRefsRecord + ? { + resultRefs: { + ...(asNonEmptyString(resultRefsRecord.commentId) + ? { commentId: asNonEmptyString(resultRefsRecord.commentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.attachmentId) + ? { attachmentId: asNonEmptyString(resultRefsRecord.attachmentId) } + : {}), + ...(asNonEmptyString(resultRefsRecord.filename) + ? { filename: asNonEmptyString(resultRefsRecord.filename) } + : {}), + }, + } + : {}), + }); + } + + return parsed; +} diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts new file mode 100644 index 00000000..b9b6d730 --- /dev/null +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -0,0 +1,400 @@ +import { TeamTaskReader } from '../../TeamTaskReader'; +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { BoardTaskLogStreamService } from '../stream/BoardTaskLogStreamService'; + +import type { ParsedMessage } from '@main/types'; +import type { TeamTask, TaskWorkInterval } from '@shared/types'; +import { getTaskDisplayId, taskMatchesRef } from '@shared/utils/taskIdentity'; + +const BOARD_MCP_TOOL_PREFIXES = ['mcp__agent-teams__', 'mcp__agent_teams__'] as const; +const MAX_EXAMPLES = 10; + +export interface BoardTaskLogDiagnosticExample { + timestamp: string; + filePath: string; + messageUuid: string; + toolUseId?: string; + toolName: string; + isSidechain: boolean; + agentId?: string; +} + +export interface BoardTaskLogDiagnosticsReport { + teamName: string; + requestedTaskRef: string; + task: { + taskId: string; + displayId: string; + subject: string; + status: TeamTask['status']; + owner?: string; + workIntervals: TaskWorkInterval[]; + }; + transcript: { + fileCount: number; + files: string[]; + }; + explicitRecords: { + total: number; + execution: number; + lifecycle: number; + boardAction: number; + participants: string[]; + toolNames: string[]; + }; + intervalToolResults: { + total: number; + boardMcp: number; + worker: { + total: number; + explicitLinked: number; + missingExplicit: number; + examples: BoardTaskLogDiagnosticExample[]; + }; + }; + stream: { + participants: string[]; + defaultFilter: string; + segmentCount: number; + visibleToolNames: string[]; + emptyPayloadExamples: BoardTaskLogDiagnosticExample[]; + }; + diagnosis: string[]; +} + +function normalizeRequestedTaskRef(taskRef: string): string { + return taskRef.trim().replace(/^#/, ''); +} + +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 isWithinWorkIntervals(timestamp: Date, intervals: TaskWorkInterval[]): boolean { + if (!Number.isFinite(timestamp.getTime())) { + return false; + } + if (intervals.length === 0) { + return true; + } + + const time = timestamp.getTime(); + return intervals.some((interval) => { + const startedAt = Date.parse(interval.startedAt); + if (!Number.isFinite(startedAt) || time < startedAt) { + return false; + } + if (!interval.completedAt) { + return true; + } + const completedAt = Date.parse(interval.completedAt); + return !Number.isFinite(completedAt) || time <= completedAt; + }); +} + +function pushUnique(values: string[], value: string | undefined): void { + if (!value) return; + if (!values.includes(value)) { + values.push(value); + } +} + +function pushExample( + examples: BoardTaskLogDiagnosticExample[], + example: BoardTaskLogDiagnosticExample +): void { + if (examples.length < MAX_EXAMPLES) { + examples.push(example); + } +} + +function buildParticipantLabel(record: BoardTaskActivityRecord): string { + if (record.actor.memberName) { + return record.actor.memberName; + } + if (!record.actor.isSidechain || record.actor.role === 'lead') { + return 'lead session'; + } + if (record.actor.agentId) { + return `member ${record.actor.agentId.slice(0, 8)}`; + } + return `member session ${record.actor.sessionId.slice(0, 8)}`; +} + +function extractVisibleToolNames( + stream: Awaited> +): string[] { + const toolNames: string[] = []; + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + pushUnique(toolNames, toolCall.name); + } + } + } + } + return toolNames; +} + +function buildStreamToolNameMap( + stream: Awaited> +): Map { + const toolNameByUseId = new Map(); + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolCall of message.toolCalls) { + toolNameByUseId.set(toolCall.id, toolCall.name); + } + } + } + } + return toolNameByUseId; +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) return true; + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function collectEmptyPayloadExamples( + stream: Awaited> +): BoardTaskLogDiagnosticExample[] { + const examples: BoardTaskLogDiagnosticExample[] = []; + const toolNameByUseId = buildStreamToolNameMap(stream); + + for (const segment of stream.segments) { + for (const chunk of segment.chunks) { + for (const message of chunk.rawMessages) { + for (const toolResult of message.toolResults) { + if (!isEmptyToolPayload(toolResult.content)) { + continue; + } + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName: toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + + const toolUseResult = message.toolUseResult; + if (!toolUseResult) { + continue; + } + const toolUseId = + typeof toolUseResult.toolUseId === 'string' + ? toolUseResult.toolUseId + : message.sourceToolUseID; + const contentIsEmpty = + (!('content' in toolUseResult) || isEmptyToolPayload(toolUseResult.content)) && + (!('message' in toolUseResult) || isEmptyToolPayload(toolUseResult.message)); + if (!contentIsEmpty) { + continue; + } + + pushExample(examples, { + timestamp: message.timestamp.toISOString(), + filePath: 'stream', + messageUuid: message.uuid, + ...(toolUseId ? { toolUseId } : {}), + toolName: toolUseId ? (toolNameByUseId.get(toolUseId) ?? 'unknown tool') : 'unknown tool', + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + return examples; +} + +function buildToolNameMap(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; +} + +export class BoardTaskLogDiagnosticsService { + constructor( + private readonly taskReader: TeamTaskReader = new TeamTaskReader(), + private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly strictParser: BoardTaskExactLogStrictParser = new BoardTaskExactLogStrictParser(), + private readonly streamService: BoardTaskLogStreamService = new BoardTaskLogStreamService() + ) {} + + async diagnose(teamName: string, taskRef: string): Promise { + const normalizedRef = normalizeRequestedTaskRef(taskRef); + const [activeTasks, deletedTasks, transcriptFiles] = await Promise.all([ + this.taskReader.getTasks(teamName), + this.taskReader.getDeletedTasks(teamName), + this.transcriptSourceLocator.listTranscriptFiles(teamName), + ]); + + const tasks = [...activeTasks, ...deletedTasks]; + const task = tasks.find((candidate) => taskMatchesRef(candidate, normalizedRef)); + if (!task) { + throw new Error(`Task "${taskRef}" was not found in team "${teamName}"`); + } + + const records = await this.recordSource.getTaskRecords(teamName, task.id); + const parsedMessagesByFile = await this.strictParser.parseFiles(transcriptFiles); + const stream = await this.streamService.getTaskLogStream(teamName, task.id); + + const toolNameByUseId = buildToolNameMap(parsedMessagesByFile); + const explicitExecutionKeys = new Set( + records + .filter((record) => record.linkKind === 'execution') + .map((record) => `${record.source.messageUuid}:${record.source.toolUseId ?? ''}`) + ); + const workIntervals = Array.isArray(task.workIntervals) ? task.workIntervals : []; + + const explicitParticipants: string[] = []; + const explicitToolNames: string[] = []; + for (const record of records) { + pushUnique(explicitParticipants, buildParticipantLabel(record)); + pushUnique(explicitToolNames, record.action?.canonicalToolName); + } + + let intervalToolResultTotal = 0; + let boardMcpToolResultTotal = 0; + let workerToolResultTotal = 0; + let explicitLinkedWorkerResultTotal = 0; + let missingExplicitWorkerResultTotal = 0; + const missingExplicitWorkerExamples: BoardTaskLogDiagnosticExample[] = []; + + for (const [filePath, messages] of parsedMessagesByFile.entries()) { + for (const message of messages) { + if (message.type !== 'user' || message.toolResults.length === 0) { + continue; + } + if (!isWithinWorkIntervals(message.timestamp, workIntervals)) { + continue; + } + + for (const toolResult of message.toolResults) { + intervalToolResultTotal += 1; + const toolName = toolNameByUseId.get(toolResult.toolUseId) ?? 'unknown tool'; + if (isBoardMcpToolName(toolName)) { + boardMcpToolResultTotal += 1; + continue; + } + + workerToolResultTotal += 1; + const explicitKey = `${message.uuid}:${toolResult.toolUseId}`; + if (explicitExecutionKeys.has(explicitKey)) { + explicitLinkedWorkerResultTotal += 1; + continue; + } + + missingExplicitWorkerResultTotal += 1; + pushExample(missingExplicitWorkerExamples, { + timestamp: message.timestamp.toISOString(), + filePath, + messageUuid: message.uuid, + toolUseId: toolResult.toolUseId, + toolName, + isSidechain: message.isSidechain, + ...(message.agentId ? { agentId: message.agentId } : {}), + }); + } + } + } + + const diagnosis: string[] = []; + if (transcriptFiles.length === 0) { + diagnosis.push('No transcript files were found for this team.'); + } + if (records.length === 0) { + diagnosis.push('No explicit task-linked activity records were found for this task.'); + } + if (missingExplicitWorkerResultTotal > 0) { + diagnosis.push( + `Only board MCP actions are explicit for part of this task history. Found ${missingExplicitWorkerResultTotal} worker tool result(s) inside task work intervals without boardTaskLinks, so Task Log Stream cannot safely include them.` + ); + } + if ( + missingExplicitWorkerResultTotal > 0 && + extractVisibleToolNames(stream).every((toolName) => isBoardMcpToolName(toolName)) + ) { + diagnosis.push( + 'Current stream visibility matches the data gap: the visible tools are MCP board actions, while worker tools exist in transcript but are unlinked.' + ); + } + + const emptyPayloadExamples = collectEmptyPayloadExamples(stream); + if (emptyPayloadExamples.length > 0) { + diagnosis.push( + `Found ${emptyPayloadExamples.length} tool result payload(s) with empty rendered content in the current stream. This explains empty success/output blocks.` + ); + } + if (diagnosis.length === 0) { + diagnosis.push('No obvious task-log data gap was detected by diagnostics.'); + } + + return { + teamName, + requestedTaskRef: taskRef, + task: { + taskId: task.id, + displayId: getTaskDisplayId(task), + subject: task.subject, + status: task.status, + ...(task.owner ? { owner: task.owner } : {}), + workIntervals, + }, + transcript: { + fileCount: transcriptFiles.length, + files: transcriptFiles, + }, + explicitRecords: { + total: records.length, + execution: records.filter((record) => record.linkKind === 'execution').length, + lifecycle: records.filter((record) => record.linkKind === 'lifecycle').length, + boardAction: records.filter((record) => record.linkKind === 'board_action').length, + participants: explicitParticipants, + toolNames: explicitToolNames, + }, + intervalToolResults: { + total: intervalToolResultTotal, + boardMcp: boardMcpToolResultTotal, + worker: { + total: workerToolResultTotal, + explicitLinked: explicitLinkedWorkerResultTotal, + missingExplicit: missingExplicitWorkerResultTotal, + examples: missingExplicitWorkerExamples, + }, + }, + stream: { + participants: stream.participants.map((participant) => participant.label), + defaultFilter: stream.defaultFilter, + segmentCount: stream.segments.length, + visibleToolNames: extractVisibleToolNames(stream), + emptyPayloadExamples, + }, + diagnosis, + }; + } +} diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts new file mode 100644 index 00000000..1daee3cb --- /dev/null +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -0,0 +1,165 @@ +import { encodePath, extractBaseDir, getProjectsBasePath } from '@main/utils/pathDecoder'; +import { createLogger } from '@shared/utils/logger'; +import * as fs from 'fs/promises'; +import * as path from 'path'; + +import { TeamConfigReader } from '../../TeamConfigReader'; + +import type { TeamConfig } from '@shared/types'; + +const logger = createLogger('Service:TeamTranscriptSourceLocator'); + +function trimTrailingSlashes(value: string): string { + let end = value.length; + while (end > 0) { + const ch = value.charCodeAt(end - 1); + if (ch === 47 || ch === 92) { + end -= 1; + continue; + } + break; + } + return end === value.length ? value : value.slice(0, end); +} + +export interface TeamTranscriptSourceContext { + projectDir: string; + projectId: string; + config: TeamConfig; + sessionIds: string[]; + transcriptFiles: string[]; +} + +export class TeamTranscriptSourceLocator { + constructor(private readonly configReader: TeamConfigReader = new TeamConfigReader()) {} + + async getContext(teamName: string): Promise { + const config = await this.configReader.getConfig(teamName); + if (!config?.projectPath) { + return null; + } + + const normalizedProjectPath = trimTrailingSlashes(config.projectPath); + let projectId = encodePath(normalizedProjectPath); + let projectDir = path.join(getProjectsBasePath(), extractBaseDir(projectId)); + + try { + const stat = await fs.stat(projectDir); + if (!stat.isDirectory()) { + throw new Error('not a directory'); + } + } catch { + const leadSessionId = + typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0 + ? config.leadSessionId.trim() + : null; + if (leadSessionId) { + try { + const projectEntries = await fs.readdir(getProjectsBasePath(), { withFileTypes: true }); + for (const entry of projectEntries) { + if (!entry.isDirectory()) continue; + const candidateDir = path.join(getProjectsBasePath(), entry.name); + try { + await fs.access(path.join(candidateDir, `${leadSessionId}.jsonl`)); + projectDir = candidateDir; + projectId = entry.name; + break; + } catch { + // not this project + } + } + } catch { + // best-effort fallback + } + } + } + + const sessionIds = await this.discoverSessionIds(projectDir, config); + const transcriptFiles = await this.listTranscriptFilesForSessions(projectDir, sessionIds); + return { projectDir, projectId, config, sessionIds, transcriptFiles }; + } + + async listTranscriptFiles(teamName: string): Promise { + const context = await this.getContext(teamName); + return context?.transcriptFiles ?? []; + } + + private async discoverSessionIds(projectDir: string, config: TeamConfig): Promise { + const knownSessionIds = new Set(); + if (typeof config.leadSessionId === 'string' && config.leadSessionId.trim().length > 0) { + knownSessionIds.add(config.leadSessionId.trim()); + } + if (Array.isArray(config.sessionHistory)) { + for (const sessionId of config.sessionHistory) { + if (typeof sessionId === 'string' && sessionId.trim().length > 0) { + knownSessionIds.add(sessionId.trim()); + } + } + } + + let discoveredSessionDirs: string[] = []; + try { + const dirEntries = await fs.readdir(projectDir, { withFileTypes: true }); + discoveredSessionDirs = dirEntries + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + } catch { + logger.debug(`Cannot read transcript project dir: ${projectDir}`); + } + + if (knownSessionIds.size === 0) { + return discoveredSessionDirs.sort(); + } + + const verifiedSessionIds: string[] = []; + for (const sessionId of knownSessionIds) { + try { + const stat = await fs.stat(path.join(projectDir, sessionId)); + if (stat.isDirectory()) { + verifiedSessionIds.push(sessionId); + } + } catch { + // ignore stale config session + } + } + + return Array.from( + new Set([...knownSessionIds, ...verifiedSessionIds, ...discoveredSessionDirs]) + ).sort(); + } + + private async listTranscriptFilesForSessions( + projectDir: string, + sessionIds: string[] + ): Promise { + const transcriptFiles = new Set(); + + for (const sessionId of sessionIds) { + const mainTranscript = path.join(projectDir, `${sessionId}.jsonl`); + try { + const stat = await fs.stat(mainTranscript); + if (stat.isFile()) { + transcriptFiles.add(mainTranscript); + } + } catch { + // ignore missing root transcript + } + + const subagentsDir = path.join(projectDir, sessionId, 'subagents'); + try { + const dirEntries = await fs.readdir(subagentsDir, { withFileTypes: true }); + for (const entry of dirEntries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.jsonl')) continue; + if (!entry.name.startsWith('agent-')) continue; + if (entry.name.startsWith('agent-acompact')) continue; + transcriptFiles.add(path.join(subagentsDir, entry.name)); + } + } catch { + // ignore missing subagent dir + } + } + + return [...transcriptFiles].sort(); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts new file mode 100644 index 00000000..963ee246 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder.ts @@ -0,0 +1,11 @@ +import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; + +import type { EnhancedChunk, ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogChunkBuilder { + constructor(private readonly chunkBuilder: ChunkBuilder = new ChunkBuilder()) {} + + buildBundleChunks(messages: ParsedMessage[]): EnhancedChunk[] { + return this.chunkBuilder.buildChunks(messages, [], { includeSidechain: true }); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts new file mode 100644 index 00000000..9a34ebe9 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts @@ -0,0 +1,364 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; +import { createLogger } from '@shared/utils/logger'; + +import type { ContentBlock, ParsedMessage } from '@main/types'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogDetailCandidate, + BoardTaskExactLogBundleCandidate, +} from './BoardTaskExactLogTypes'; + +const logger = createLogger('Service:BoardTaskExactLogDetailSelector'); + +interface TentativeFilteredMessage { + original: ParsedMessage; + filteredContent: ParsedMessage['content']; + matchedToolUseId?: string; +} + +function isToolAnchoredOutputMessage( + message: ParsedMessage, + toolUseId: string | undefined +): boolean { + return Boolean(toolUseId && message.sourceToolUseID === toolUseId); +} + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function keepExplicitTextualBlock(block: ContentBlock): boolean { + return block.type === 'text' || block.type === 'image'; +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function filterAssistantContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_use') { + if (toolUseId && block.id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (block.type === 'thinking') { + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterUserArrayContent( + content: ContentBlock[], + toolUseId: string | undefined, + explicitMessageLinked: boolean +): ContentBlock[] { + const kept: ContentBlock[] = []; + + for (const block of content) { + if (block.type === 'tool_result') { + if (toolUseId && block.tool_use_id === toolUseId) { + kept.push(cloneBlock(block)); + } + continue; + } + + if (explicitMessageLinked && keepExplicitTextualBlock(block)) { + kept.push(cloneBlock(block)); + } + } + + return kept; +} + +function filterMessageForCandidate(args: { + message: ParsedMessage; + candidate: BoardTaskExactLogBundleCandidate; + explicitMessageIds: Set; +}): TentativeFilteredMessage | null { + const { message, candidate, explicitMessageIds } = args; + const explicitMessageLinked = explicitMessageIds.has(message.uuid); + const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined; + const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId); + + if (typeof message.content === 'string') { + if (!explicitMessageLinked && !anchoredOutputLinked) { + return null; + } + return { + original: message, + filteredContent: message.content, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; + } + + let filteredBlocks: ContentBlock[] = []; + if (message.type === 'assistant') { + filteredBlocks = filterAssistantContent( + message.content, + toolUseId, + explicitMessageLinked || anchoredOutputLinked + ); + } else if (message.type === 'user') { + filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked); + } else { + filteredBlocks = explicitMessageLinked + ? message.content.filter(keepExplicitTextualBlock).map((block) => cloneBlock(block)) + : []; + } + + if (filteredBlocks.length === 0) { + return null; + } + + return { + original: message, + filteredContent: filteredBlocks, + ...(toolUseId ? { matchedToolUseId: toolUseId } : {}), + }; +} + +function rebuildParsedMessage( + message: ParsedMessage, + filteredContent: ParsedMessage['content'], + keptAssistantUuids: Set, + matchedToolUseId?: string +): ParsedMessage { + const { + toolCalls: _originalToolCalls, + toolResults: _originalToolResults, + sourceToolUseID: _originalSourceToolUseID, + sourceToolAssistantUUID: _originalSourceToolAssistantUUID, + toolUseResult: _originalToolUseResult, + ...baseMessage + } = message; + const toolCalls = extractToolCalls(filteredContent); + const toolResults = extractToolResults(filteredContent); + const singleToolResult = toolResults.length === 1 ? toolResults[0] : undefined; + const matchedToolUseResultId = + message.toolUseResult && + typeof message.toolUseResult.toolUseId === 'string' && + message.toolUseResult.toolUseId === matchedToolUseId + ? matchedToolUseId + : undefined; + const matchedSourceToolUseId = + matchedToolUseId && + (message.sourceToolUseID === matchedToolUseId || + singleToolResult?.toolUseId === matchedToolUseId || + matchedToolUseResultId === matchedToolUseId) + ? matchedToolUseId + : undefined; + const matchedSourceToolAssistantUUID = + matchedToolUseId && + message.sourceToolAssistantUUID && + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ? message.sourceToolAssistantUUID + : undefined; + const toolUseResult = + matchedToolUseId && + matchedSourceToolUseId === matchedToolUseId && + singleToolResult?.toolUseId === matchedToolUseId + ? message.toolUseResult + : undefined; + + return { + ...baseMessage, + content: filteredContent, + toolCalls, + toolResults, + ...(matchedSourceToolUseId ? { sourceToolUseID: matchedSourceToolUseId } : {}), + ...(matchedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: matchedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function anchorEvidenceRank(message: ParsedMessage, toolUseId: string | undefined): number { + if (message.type !== 'assistant' || !toolUseId) { + return 0; + } + + if (Array.isArray(message.content)) { + for (const block of message.content) { + if (block.type === 'tool_use' && block.id === toolUseId) { + return 2; + } + } + } + + return message.sourceToolUseID === toolUseId ? 1 : 0; +} + +function deduplicateAssistantMessagesByRequestId( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + const preferredAssistantIndexByRequestId = new Map(); + for (let i = 0; i < messages.length; i++) { + const message = messages[i]; + if (message.type === 'assistant' && message.requestId) { + const existingIndex = preferredAssistantIndexByRequestId.get(message.requestId); + if (existingIndex === undefined) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + continue; + } + + const existingRank = anchorEvidenceRank(messages[existingIndex]!, toolUseId); + const nextRank = anchorEvidenceRank(message, toolUseId); + if (nextRank > existingRank || (nextRank === existingRank && i > existingIndex)) { + preferredAssistantIndexByRequestId.set(message.requestId, i); + } + } + } + + if (preferredAssistantIndexByRequestId.size === 0) { + return messages; + } + + return messages.filter((message, index) => { + if (message.type !== 'assistant' || !message.requestId) { + return true; + } + return preferredAssistantIndexByRequestId.get(message.requestId) === index; + }); +} + +function sanitizeSourceAssistantLinks(messages: ParsedMessage[]): ParsedMessage[] { + const keptAssistantUuids = new Set( + messages.filter((message) => message.type === 'assistant').map((message) => message.uuid) + ); + + return messages.map((message) => { + if ( + !message.sourceToolAssistantUUID || + keptAssistantUuids.has(message.sourceToolAssistantUUID) + ) { + return message; + } + + const { sourceToolAssistantUUID: _ignored, ...rest } = message; + return rest; + }); +} + +export class BoardTaskExactLogDetailSelector { + selectDetail(args: { + candidate: BoardTaskExactLogBundleCandidate; + records: BoardTaskActivityRecord[]; + parsedMessagesByFile: Map; + }): BoardTaskExactLogDetailCandidate | null { + const { candidate, records, parsedMessagesByFile } = args; + const relevantRecords = records.filter((record) => + candidate.records.some((row) => row.id === record.id) + ); + if (relevantRecords.length === 0) { + noteExactDiagnostic('missing_records_for_detail', { id: candidate.id }); + return null; + } + + const parsedMessages = parsedMessagesByFile.get(candidate.source.filePath); + if (!parsedMessages || parsedMessages.length === 0) { + noteExactDiagnostic('missing_parsed_messages', { filePath: candidate.source.filePath }); + return null; + } + + const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid)); + const tentative: TentativeFilteredMessage[] = []; + + for (const message of parsedMessages) { + const filtered = filterMessageForCandidate({ + message, + candidate, + explicitMessageIds, + }); + if (filtered) { + tentative.push(filtered); + } + } + + if (tentative.length === 0) { + noteExactDiagnostic('empty_filtered_bundle', { id: candidate.id }); + return null; + } + + const keptAssistantUuids = new Set( + tentative + .filter((entry) => entry.original.type === 'assistant') + .map((entry) => entry.original.uuid) + ); + + const rebuilt = tentative.map((entry) => + rebuildParsedMessage( + entry.original, + entry.filteredContent, + keptAssistantUuids, + entry.matchedToolUseId + ) + ); + + const deduped = deduplicateAssistantMessagesByRequestId( + rebuilt, + candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined + ); + const sanitized = sanitizeSourceAssistantLinks(deduped); + if (sanitized.length === 0) { + noteExactDiagnostic('empty_deduped_bundle', { id: candidate.id }); + return null; + } + + return { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: sanitized, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts new file mode 100644 index 00000000..bdb4c0ee --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService.ts @@ -0,0 +1,76 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from './BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from './BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from './BoardTaskExactLogStrictParser'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; + +import type { BoardTaskExactLogDetailResult } from '@shared/types'; + +export class BoardTaskExactLogDetailService { + 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() + ) {} + + async getTaskExactLogDetail( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { status: 'missing' }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { status: 'missing' }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidate = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .find((item) => item.id === exactLogId); + + if (!candidate) { + return { status: 'missing' }; + } + if (!candidate.canLoadDetail) { + return { status: 'missing' }; + } + if (candidate.sourceGeneration !== expectedSourceGeneration) { + return { status: 'stale' }; + } + + const parsedMessagesByFile = await this.strictParser.parseFiles([candidate.source.filePath]); + const detailCandidate = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + if (!detailCandidate) { + return { status: 'missing' }; + } + + const chunks = this.chunkBuilder.buildBundleChunks(detailCandidate.filteredMessages); + return { + status: 'ok', + detail: { + id: detailCandidate.id, + chunks, + }, + }; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts new file mode 100644 index 00000000..0927411a --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts @@ -0,0 +1,106 @@ +import { createLogger } from '@shared/utils/logger'; +import { createReadStream } from 'fs'; +import * as fs from 'fs/promises'; +import * as readline from 'readline'; + +import { yieldToEventLoop } from '@main/utils/asyncYield'; +import { parseJsonlLine } from '@main/utils/jsonl'; + +import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache'; + +import type { ParsedMessage } from '@main/types'; + +const logger = createLogger('Service:BoardTaskExactLogStrictParser'); + +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' ? (value as Record) : null; +} + +function hasStrictTimestamp(record: Record): boolean { + if (typeof record.timestamp !== 'string' || record.timestamp.trim().length === 0) { + return false; + } + return Number.isFinite(Date.parse(record.timestamp)); +} + +export class BoardTaskExactLogStrictParser { + constructor( + private readonly cache: BoardTaskExactLogsParseCache = new BoardTaskExactLogsParseCache() + ) {} + + async parseFiles(filePaths: string[]): Promise> { + const uniquePaths = [...new Set(filePaths)].sort(); + this.cache.retainOnly(new Set(uniquePaths)); + + const results = await Promise.all( + uniquePaths.map(async (filePath) => [filePath, await this.parseFile(filePath)] as const) + ); + + return new Map(results); + } + + private async parseFile(filePath: string): Promise { + try { + const stat = await fs.stat(filePath); + const cached = this.cache.getIfFresh(filePath, stat.mtimeMs, stat.size); + if (cached) { + return cached; + } + + const inFlight = this.cache.getInFlight(filePath); + if (inFlight) { + return inFlight; + } + + const promise = this.readStrictFile(filePath); + this.cache.setInFlight(filePath, promise); + try { + const parsed = await promise; + this.cache.set(filePath, stat.mtimeMs, stat.size, parsed); + return parsed; + } finally { + this.cache.clearInFlight(filePath); + } + } catch (error) { + logger.debug(`Skipping unreadable exact-log transcript ${filePath}: ${String(error)}`); + this.cache.clearForPath(filePath); + return []; + } + } + + private async readStrictFile(filePath: string): Promise { + const results: ParsedMessage[] = []; + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const rl = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + let lineCount = 0; + for await (const line of rl) { + if (!line.trim()) continue; + lineCount += 1; + + try { + const raw = JSON.parse(line) as unknown; + const record = asRecord(raw); + if (!record || !hasStrictTimestamp(record)) { + continue; + } + + const parsed = parseJsonlLine(line); + if (parsed) { + results.push(parsed); + } + } catch (error) { + logger.debug(`Skipping malformed exact-log line in ${filePath}: ${String(error)}`); + } + + if (lineCount % 250 === 0) { + await yieldToEventLoop(); + } + } + + return results; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts new file mode 100644 index 00000000..74b3c88e --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector.ts @@ -0,0 +1,227 @@ +import { createHash } from 'crypto'; + +import { describeBoardTaskActivityLabel } from '@shared/utils/boardTaskActivityLabels'; +import { createLogger } from '@shared/utils/logger'; + +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogAnchor, + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogFileVersion, +} from './BoardTaskExactLogTypes'; + +const logger = createLogger('Service:BoardTaskExactLogSummarySelector'); + +function noteExactDiagnostic( + event: string, + details: Record = {} +): void { + const suffix = Object.entries(details) + .filter(([, value]) => value !== undefined) + .map(([key, value]) => `${key}=${String(value)}`) + .join(' '); + + logger.debug(`[board_task_exact_logs.${event}]${suffix ? ` ${suffix}` : ''}`); +} + +function compareCandidateTimestamps( + left: BoardTaskActivityRecord, + right: BoardTaskActivityRecord +): 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.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); +} + +function buildMessageGroupKey(record: BoardTaskActivityRecord): string { + return `${record.source.filePath}:${record.source.messageUuid}`; +} + +function buildToolAnchor( + filePath: string, + messageUuid: string, + toolUseId: string +): BoardTaskExactLogAnchor { + return { + kind: 'tool', + filePath, + messageUuid, + toolUseId, + }; +} + +function buildMessageAnchor(filePath: string, messageUuid: string): BoardTaskExactLogAnchor { + return { + kind: 'message', + filePath, + messageUuid, + }; +} + +function anchorId(anchor: BoardTaskExactLogAnchor): string { + return anchor.kind === 'tool' + ? `tool:${anchor.filePath}:${anchor.toolUseId ?? ''}` + : `message:${anchor.filePath}:${anchor.messageUuid}`; +} + +function sourceGenerationFor( + anchor: BoardTaskExactLogAnchor, + version: BoardTaskExactLogFileVersion | undefined +): string | null { + if (!version) return null; + const hash = createHash('sha1'); + hash.update(anchor.filePath); + hash.update('\0'); + hash.update(String(version.size)); + hash.update('\0'); + hash.update(String(version.mtimeMs)); + return hash.digest('hex'); +} + +function chooseSummaryRecord( + records: BoardTaskActivityRecord[], + anchor: BoardTaskExactLogAnchor +): BoardTaskActivityRecord | null { + if (records.length === 0) { + return null; + } + + const anchoredRecords = + anchor.kind === 'tool' && anchor.toolUseId + ? records.filter( + (record) => + record.source.toolUseId === anchor.toolUseId || + record.action?.toolUseId === anchor.toolUseId + ) + : records; + const candidates = anchoredRecords.length > 0 ? anchoredRecords : records; + + return ( + candidates.find((record) => record.action?.canonicalToolName) ?? + candidates.find((record) => record.linkKind !== 'execution' && record.action) ?? + candidates[0] ?? + null + ); +} + +export class BoardTaskExactLogSummarySelector { + selectSummaries(args: { + records: BoardTaskActivityRecord[]; + fileVersionsByPath: Map; + }): BoardTaskExactLogBundleCandidate[] { + const byMessage = new Map(); + for (const record of args.records) { + const key = buildMessageGroupKey(record); + const bucket = byMessage.get(key) ?? []; + bucket.push(record); + byMessage.set(key, bucket); + } + + const groups = new Map< + string, + { anchor: BoardTaskExactLogAnchor; records: BoardTaskActivityRecord[] } + >(); + + for (const messageRecords of byMessage.values()) { + const sortedMessageRecords = [...messageRecords].sort(compareCandidateTimestamps); + const toolUseIds = [ + ...new Set(sortedMessageRecords.map((record) => record.source.toolUseId).filter(Boolean)), + ] as string[]; + const singleToolUseId = toolUseIds.length === 1 ? toolUseIds[0] : null; + + for (const record of sortedMessageRecords) { + let anchor: BoardTaskExactLogAnchor; + if (record.source.toolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + record.source.toolUseId + ); + } else if (singleToolUseId) { + anchor = buildToolAnchor( + record.source.filePath, + record.source.messageUuid, + singleToolUseId + ); + } else { + anchor = buildMessageAnchor(record.source.filePath, record.source.messageUuid); + } + + const key = anchorId(anchor); + const existing = groups.get(key); + if (existing) { + existing.records.push(record); + } else { + groups.set(key, { anchor, records: [record] }); + } + } + } + + const candidates: BoardTaskExactLogBundleCandidate[] = []; + + for (const [key, group] of groups) { + const sortedRecords = [...group.records].sort(compareCandidateTimestamps); + const primaryRecord = sortedRecords[0]; + if (!primaryRecord) { + continue; + } + + const linkKinds = [...new Set(sortedRecords.map((record) => record.linkKind))]; + const targetRoles = [...new Set(sortedRecords.map((record) => record.targetRole))]; + const fileVersion = args.fileVersionsByPath.get(primaryRecord.source.filePath); + const sourceGeneration = sourceGenerationFor(group.anchor, fileVersion); + const summaryRecord = chooseSummaryRecord(sortedRecords, group.anchor) ?? primaryRecord; + const actionLabel = describeBoardTaskActivityLabel(summaryRecord); + + const baseCandidate = { + id: key, + timestamp: primaryRecord.timestamp, + actor: primaryRecord.actor, + source: { + filePath: primaryRecord.source.filePath, + messageUuid: primaryRecord.source.messageUuid, + ...(group.anchor.kind === 'tool' && group.anchor.toolUseId + ? { toolUseId: group.anchor.toolUseId } + : {}), + sourceOrder: primaryRecord.source.sourceOrder, + }, + records: sortedRecords, + anchor: group.anchor, + actionLabel, + ...(summaryRecord.action?.category + ? { actionCategory: summaryRecord.action.category } + : {}), + ...(summaryRecord.action?.canonicalToolName + ? { canonicalToolName: summaryRecord.action.canonicalToolName } + : {}), + linkKinds, + targetRoles, + }; + + if (sourceGeneration) { + candidates.push({ + ...baseCandidate, + canLoadDetail: true, + sourceGeneration, + }); + } else { + noteExactDiagnostic('non_expandable_summary', { + filePath: primaryRecord.source.filePath, + toolUseId: group.anchor.toolUseId, + }); + candidates.push({ + ...baseCandidate, + canLoadDetail: false, + }); + } + } + + return candidates; + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts new file mode 100644 index 00000000..31550a5b --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes.ts @@ -0,0 +1,77 @@ +import type { ParsedMessage } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskActivityLinkKind, + BoardTaskActivityTargetRole, + BoardTaskExactLogActor, + BoardTaskExactLogSource, + BoardTaskExactLogSummary, +} from '@shared/types'; +import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; + +export interface BoardTaskExactLogFileVersion { + filePath: string; + mtimeMs: number; + size: number; +} + +export interface BoardTaskExactLogAnchor { + kind: 'tool' | 'message'; + filePath: string; + messageUuid: string; + toolUseId?: string; +} + +export type BoardTaskExactLogBundleCandidate = { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + anchor: BoardTaskExactLogAnchor; + actionLabel: string; + actionCategory?: BoardTaskActivityCategory; + canonicalToolName?: string; + linkKinds: BoardTaskActivityLinkKind[]; + targetRoles: BoardTaskActivityTargetRole[]; +} & ({ canLoadDetail: true; sourceGeneration: string } | { canLoadDetail: false }); + +export interface BoardTaskExactLogDetailCandidate { + id: string; + timestamp: string; + actor: BoardTaskExactLogActor; + source: BoardTaskExactLogSource; + records: BoardTaskActivityRecord[]; + filteredMessages: ParsedMessage[]; +} + +export function mapCandidateToSummary( + candidate: BoardTaskExactLogBundleCandidate +): BoardTaskExactLogSummary { + return candidate.canLoadDetail + ? { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: true, + sourceGeneration: candidate.sourceGeneration, + } + : { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + anchorKind: candidate.anchor.kind, + actionLabel: candidate.actionLabel, + ...(candidate.actionCategory ? { actionCategory: candidate.actionCategory } : {}), + ...(candidate.canonicalToolName ? { canonicalToolName: candidate.canonicalToolName } : {}), + linkKinds: candidate.linkKinds, + canLoadDetail: false, + }; +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts new file mode 100644 index 00000000..439fe186 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsParseCache.ts @@ -0,0 +1,35 @@ +import { BoardTaskActivityParseCache } from '../activity/BoardTaskActivityParseCache'; + +import type { ParsedMessage } from '@main/types'; + +export class BoardTaskExactLogsParseCache { + private readonly cache = new BoardTaskActivityParseCache(); + + getIfFresh(filePath: string, mtimeMs: number, size: number): ParsedMessage[] | null { + return this.cache.getIfFresh(filePath, mtimeMs, size); + } + + getInFlight(filePath: string): Promise | null { + return this.cache.getInFlight(filePath); + } + + setInFlight(filePath: string, promise: Promise): void { + this.cache.setInFlight(filePath, promise); + } + + clearInFlight(filePath: string): void { + this.cache.clearInFlight(filePath); + } + + set(filePath: string, mtimeMs: number, size: number, value: ParsedMessage[]): void { + this.cache.set(filePath, mtimeMs, size, value); + } + + clearForPath(filePath: string): void { + this.cache.clearForPath(filePath); + } + + retainOnly(filePaths: Set): void { + this.cache.retainOnly(filePaths); + } +} diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts new file mode 100644 index 00000000..12db7d35 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogsService.ts @@ -0,0 +1,63 @@ +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { isBoardTaskExactLogsReadEnabled } from './featureGates'; +import { getBoardTaskExactLogFileVersions } from './fileVersions'; +import { BoardTaskExactLogSummarySelector } from './BoardTaskExactLogSummarySelector'; +import { mapCandidateToSummary } from './BoardTaskExactLogTypes'; + +import type { BoardTaskExactLogSummariesResponse } from '@shared/types'; + +function compareSummaries( + left: BoardTaskExactLogSummariesResponse['items'][number], + right: BoardTaskExactLogSummariesResponse['items'][number] +): 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.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +export class BoardTaskExactLogsService { + constructor( + private readonly recordSource: BoardTaskActivityRecordSource = new BoardTaskActivityRecordSource(), + private readonly summarySelector: BoardTaskExactLogSummarySelector = new BoardTaskExactLogSummarySelector() + ) {} + + async getTaskExactLogSummaries( + teamName: string, + taskId: string + ): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return { items: [] }; + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return { items: [] }; + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const items = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .map(mapCandidateToSummary) + .sort(compareSummaries); + + return { items }; + } +} diff --git a/src/main/services/team/taskLogs/exact/featureGates.ts b/src/main/services/team/taskLogs/exact/featureGates.ts new file mode 100644 index 00000000..f5f86270 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/featureGates.ts @@ -0,0 +1,18 @@ +function readEnabledFlag(value: string | undefined, defaultValue: boolean): boolean { + if (value == null) { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskExactLogsReadEnabled(): boolean { + return readEnabledFlag(process.env.CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED, true); +} diff --git a/src/main/services/team/taskLogs/exact/fileVersions.ts b/src/main/services/team/taskLogs/exact/fileVersions.ts new file mode 100644 index 00000000..879ab4b7 --- /dev/null +++ b/src/main/services/team/taskLogs/exact/fileVersions.ts @@ -0,0 +1,33 @@ +import * as fs from 'fs/promises'; + +import type { BoardTaskExactLogFileVersion } from './BoardTaskExactLogTypes'; + +export async function getBoardTaskExactLogFileVersions( + filePaths: Iterable +): Promise> { + const uniqueFilePaths = [...new Set(filePaths)]; + const results = await Promise.all( + uniqueFilePaths.map(async (filePath) => { + try { + const stat = await fs.stat(filePath); + if (!stat.isFile()) { + return null; + } + return { + filePath, + mtimeMs: stat.mtimeMs, + size: stat.size, + } satisfies BoardTaskExactLogFileVersion; + } catch { + return null; + } + }) + ); + + const byPath = new Map(); + for (const item of results) { + if (!item) continue; + byPath.set(item.filePath, item); + } + return byPath; +} diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts new file mode 100644 index 00000000..078f2ca8 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -0,0 +1,858 @@ +import { extractToolCalls, extractToolResults } from '@main/utils/toolExtraction'; + +import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; +import { BoardTaskExactLogChunkBuilder } from '../exact/BoardTaskExactLogChunkBuilder'; +import { BoardTaskExactLogDetailSelector } from '../exact/BoardTaskExactLogDetailSelector'; +import { BoardTaskExactLogStrictParser } from '../exact/BoardTaskExactLogStrictParser'; +import { isBoardTaskExactLogsReadEnabled } from '../exact/featureGates'; +import { getBoardTaskExactLogFileVersions } from '../exact/fileVersions'; +import { BoardTaskExactLogSummarySelector } from '../exact/BoardTaskExactLogSummarySelector'; + +import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; +import type { + BoardTaskActivityCategory, + BoardTaskLogActor, + BoardTaskLogParticipant, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, +} from '@shared/types'; +import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; + +interface StreamSlice { + id: string; + timestamp: string; + filePath: string; + participantKey: string; + actor: BoardTaskLogActor; + actionCategory?: BoardTaskActivityCategory; + filteredMessages: ParsedMessage[]; +} + +interface MergedMessageAccumulator { + message: ParsedMessage; + content: ParsedMessage['content']; + firstSeenOrder: number; + sourceToolUseIds: Set; + sourceToolAssistantUUIDs: Set; + toolUseResults: ToolUseResultData[]; +} + +function emptyResponse(): BoardTaskLogStreamResponse { + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; +} + +function normalizeMemberName(value: string): string { + return value.trim().toLowerCase(); +} + +function toStreamActor(detail: BoardTaskExactLogDetailCandidate['actor']): BoardTaskLogActor { + return { + ...(detail.memberName ? { memberName: detail.memberName } : {}), + role: detail.role, + sessionId: detail.sessionId, + ...(detail.agentId ? { agentId: detail.agentId } : {}), + isSidechain: detail.isSidechain, + }; +} + +function buildParticipantKey(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return `member:${normalizeMemberName(actor.memberName)}`; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead'; + } + if (actor.agentId) { + return `sidechain-agent:${actor.agentId}`; + } + return `sidechain-session:${actor.sessionId}`; +} + +function buildParticipantLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (!actor.isSidechain || actor.role === 'lead') { + return 'lead session'; + } + if (actor.agentId) { + return `member ${actor.agentId.slice(0, 8)}`; + } + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +function buildParticipant( + actor: BoardTaskLogActor, + participantKey: string +): BoardTaskLogParticipant { + return { + key: participantKey, + label: buildParticipantLabel(actor), + role: actor.role, + isLead: participantKey === 'lead', + isSidechain: actor.isSidechain, + }; +} + +function hasNamedParticipant(actor: BoardTaskLogActor): boolean { + return typeof actor.memberName === 'string' && actor.memberName.trim().length > 0; +} + +function hasToolUseBlock( + content: ParsedMessage['content'], + toolUseId: string | undefined +): boolean { + if (!toolUseId || typeof content === 'string') { + return false; + } + + return content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function looksLikeJsonPayload(value: string): boolean { + const trimmed = value.trim(); + return trimmed.startsWith('{') || trimmed.startsWith('['); +} + +function parseJsonLikeString(value: string): unknown { + const trimmed = value.trim(); + if (!looksLikeJsonPayload(trimmed)) { + return null; + } + + try { + return JSON.parse(trimmed); + } catch { + return null; + } +} + +function extractBoardToolOutputText( + toolName: string | undefined, + parsedPayload: unknown +): string | null { + if (!toolName || !parsedPayload || typeof parsedPayload !== 'object') { + return null; + } + + const payload = parsedPayload as Record; + if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { + const comment = payload.comment as Record | undefined; + if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { + return comment.text; + } + } + + return null; +} + +function collectTextBlockText(value: unknown): string { + if (!Array.isArray(value)) { + return ''; + } + + return value + .filter( + (child): child is Extract => + typeof child === 'object' && + child !== null && + 'type' in child && + child.type === 'text' && + 'text' in child && + typeof child.text === 'string' + ) + .map((child) => child.text) + .join('\n'); +} + +function isEmptyToolPayload(value: unknown): boolean { + if (value == null) { + return true; + } + if (typeof value === 'string') { + return value.trim().length === 0; + } + if (Array.isArray(value)) { + return value.length === 0; + } + return false; +} + +function inferSingleToolUseId(message: ParsedMessage): string | undefined { + if (message.sourceToolUseID) { + return message.sourceToolUseID; + } + + if (message.toolResults.length === 1) { + return message.toolResults[0]?.toolUseId; + } + + if (!Array.isArray(message.content)) { + return undefined; + } + + const uniqueIds = new Set( + message.content + .filter( + (block): block is Extract => + block.type === 'tool_result' + ) + .map((block) => block.tool_use_id) + ); + + return uniqueIds.size === 1 ? uniqueIds.values().next().value : undefined; +} + +function sanitizeToolResultContent( + content: ContentBlock, + canonicalToolName?: string +): ContentBlock { + if (content.type !== 'tool_result') { + return cloneBlock(content); + } + + if (typeof content.content === 'string') { + const parsedPayload = parseJsonLikeString(content.content); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: [{ type: 'text', text: extractedText }], + }; + } + return parsedPayload ? { ...content, content: '' } : cloneBlock(content); + } + + if (!Array.isArray(content.content)) { + return cloneBlock(content); + } + + const jsonText = content.content + .filter((child): child is Extract => child.type === 'text') + .map((child) => child.text) + .join('\n'); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string') { + return { + ...content, + content: extractedText, + }; + } + + const sanitizedChildren = content.content + .map((child) => { + if (child.type !== 'text') { + return cloneBlock(child); + } + + return looksLikeJsonPayload(child.text) ? null : cloneBlock(child); + }) + .filter((child): child is ContentBlock => child !== null); + + if (sanitizedChildren.length === 0) { + return { + ...content, + content: '', + }; + } + + return { + ...content, + content: sanitizedChildren, + }; +} + +function sanitizeJsonLikeToolResultPayloads( + messages: ParsedMessage[], + canonicalToolName?: string +): ParsedMessage[] { + return messages.map((message) => { + let nextMessage = message; + + const rawToolUseResult = message.toolUseResult as unknown; + if ( + rawToolUseResult && + typeof rawToolUseResult === 'object' && + !Array.isArray(rawToolUseResult) + ) { + const nextToolUseResult: Record & { + content?: unknown; + message?: unknown; + } = { ...(rawToolUseResult as Record) }; + let toolUseResultChanged = false; + const extractedFromContent = + typeof nextToolUseResult.content === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.content) + ) + : null; + const extractedFromMessage = + typeof nextToolUseResult.message === 'string' + ? extractBoardToolOutputText( + canonicalToolName, + parseJsonLikeString(nextToolUseResult.message) + ) + : null; + + if (typeof extractedFromContent === 'string') { + nextToolUseResult.content = extractedFromContent; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.content === 'string' && + looksLikeJsonPayload(nextToolUseResult.content) + ) { + nextToolUseResult.content = ''; + toolUseResultChanged = true; + } + + if (typeof extractedFromMessage === 'string') { + nextToolUseResult.message = extractedFromMessage; + toolUseResultChanged = true; + } + + if ( + typeof nextToolUseResult.message === 'string' && + looksLikeJsonPayload(nextToolUseResult.message) + ) { + nextToolUseResult.message = ''; + toolUseResultChanged = true; + } + + if (toolUseResultChanged) { + nextMessage = { + ...nextMessage, + toolUseResult: nextToolUseResult, + }; + } + } else if (Array.isArray(rawToolUseResult)) { + const toolUseId = inferSingleToolUseId(message); + const jsonText = collectTextBlockText(rawToolUseResult); + const parsedPayload = parseJsonLikeString(jsonText); + const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); + if (typeof extractedText === 'string' || parsedPayload) { + nextMessage = { + ...nextMessage, + toolUseResult: { + ...(toolUseId ? { toolUseId } : {}), + content: typeof extractedText === 'string' ? extractedText : '', + }, + }; + } + } + + if (typeof message.content === 'string') { + return nextMessage; + } + + let changed = false; + const nextContent = message.content.map((block) => { + if (block.type !== 'tool_result') { + return block; + } + + const sanitized = sanitizeToolResultContent(block, canonicalToolName); + if (JSON.stringify(sanitized) !== JSON.stringify(block)) { + changed = true; + } + return sanitized; + }); + + if (!changed) { + return nextMessage; + } + + return { + ...nextMessage, + content: nextContent, + }; + }); +} + +function hasMeaningfulToolUseResult(message: ParsedMessage): boolean { + const rawToolUseResult = message.toolUseResult as unknown; + if ( + !rawToolUseResult || + typeof rawToolUseResult !== 'object' || + Array.isArray(rawToolUseResult) + ) { + return false; + } + + const toolUseResult = rawToolUseResult as { + error?: unknown; + stderr?: unknown; + content?: unknown; + message?: unknown; + }; + if (typeof toolUseResult.error === 'string' && toolUseResult.error.trim().length > 0) { + return true; + } + if (typeof toolUseResult.stderr === 'string' && toolUseResult.stderr.trim().length > 0) { + return true; + } + if (typeof toolUseResult.content === 'string' && toolUseResult.content.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.content) && toolUseResult.content.length > 0) { + return true; + } + if (typeof toolUseResult.message === 'string' && toolUseResult.message.trim().length > 0) { + return true; + } + if (Array.isArray(toolUseResult.message) && toolUseResult.message.length > 0) { + return true; + } + return false; +} + +function pruneEmptyInternalToolResultMessages(messages: ParsedMessage[]): ParsedMessage[] { + return messages.filter((message) => { + if ( + message.type !== 'user' || + message.toolResults.length === 0 || + typeof message.content === 'string' + ) { + return true; + } + + const hasNonToolResultContent = message.content.some((block) => block.type !== 'tool_result'); + if (hasNonToolResultContent) { + return true; + } + + const allToolResultsEmpty = message.toolResults.every((toolResult) => + isEmptyToolPayload(toolResult.content) + ); + if (!allToolResultsEmpty) { + return true; + } + + return hasMeaningfulToolUseResult(message); + }); +} + +function pruneToolAnchoredAssistantOutputMessages( + messages: ParsedMessage[], + toolUseId: string | undefined +): ParsedMessage[] { + if (!toolUseId) { + return messages; + } + + return messages.filter((message) => { + if (message.type !== 'assistant') { + return true; + } + if (message.sourceToolUseID !== toolUseId) { + return true; + } + return hasToolUseBlock(message.content, toolUseId); + }); +} + +function filterReadOnlySlices(slices: StreamSlice[]): StreamSlice[] { + const participantHasNonRead = new Map(); + + for (const slice of slices) { + if (slice.actionCategory && slice.actionCategory !== 'read') { + participantHasNonRead.set(slice.participantKey, true); + } + } + + return slices.filter((slice) => { + const hasNonReadForParticipant = participantHasNonRead.get(slice.participantKey) === true; + if (!hasNonReadForParticipant) { + return true; + } + return slice.actionCategory !== 'read'; + }); +} + +function compareCandidates( + left: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + }, + right: { + id: string; + timestamp: string; + source: { filePath: string; sourceOrder: number; toolUseId?: string }; + } +): 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.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + if ((left.source.toolUseId ?? '') !== (right.source.toolUseId ?? '')) { + return (left.source.toolUseId ?? '').localeCompare(right.source.toolUseId ?? ''); + } + return left.id.localeCompare(right.id); +} + +function blockKey(block: ContentBlock): string { + return JSON.stringify(block); +} + +function cloneBlock(block: T): T { + if (block.type === 'tool_use') { + return { + ...block, + input: { ...(block.input ?? {}) }, + } as T; + } + + if (block.type === 'tool_result') { + return { + ...block, + content: Array.isArray(block.content) + ? block.content.map((child) => cloneBlock(child)) + : block.content, + } as T; + } + + if (block.type === 'image') { + return { + ...block, + source: { ...block.source }, + } as T; + } + + return { ...block } as T; +} + +function cloneMessageContent(content: ParsedMessage['content']): ParsedMessage['content'] { + if (typeof content === 'string') { + return content; + } + return content.map((block) => cloneBlock(block)); +} + +function mergeMessageContent( + current: ParsedMessage['content'], + incoming: ParsedMessage['content'] +): ParsedMessage['content'] { + if (typeof current === 'string') { + return current; + } + if (typeof incoming === 'string') { + return current; + } + + const merged = current.map((block) => cloneBlock(block)); + const seen = new Set(merged.map((block) => blockKey(block))); + for (const block of incoming) { + const key = blockKey(block); + if (seen.has(key)) continue; + merged.push(cloneBlock(block)); + seen.add(key); + } + return merged; +} + +function createAccumulator( + message: ParsedMessage, + firstSeenOrder: number +): MergedMessageAccumulator { + return { + message, + content: cloneMessageContent(message.content), + firstSeenOrder, + sourceToolUseIds: new Set(message.sourceToolUseID ? [message.sourceToolUseID] : []), + sourceToolAssistantUUIDs: new Set( + message.sourceToolAssistantUUID ? [message.sourceToolAssistantUUID] : [] + ), + toolUseResults: message.toolUseResult ? [message.toolUseResult] : [], + }; +} + +function updateAccumulator(accumulator: MergedMessageAccumulator, message: ParsedMessage): void { + accumulator.content = mergeMessageContent(accumulator.content, message.content); + if (message.sourceToolUseID) { + accumulator.sourceToolUseIds.add(message.sourceToolUseID); + } + if (message.sourceToolAssistantUUID) { + accumulator.sourceToolAssistantUUIDs.add(message.sourceToolAssistantUUID); + } + if (message.toolUseResult) { + accumulator.toolUseResults.push(message.toolUseResult); + } +} + +function selectSingleValue(values: Set): string | undefined { + if (values.size !== 1) return undefined; + return values.values().next().value; +} + +function selectSingleToolUseResult(values: ToolUseResultData[]): ToolUseResultData | undefined { + if (values.length !== 1) return undefined; + return values[0]; +} + +function extractToolUseIdFromToolUseResult( + value: ToolUseResultData | undefined +): string | undefined { + if (!value || typeof value.toolUseId !== 'string') { + return undefined; + } + const trimmed = value.toolUseId.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + +function rebuildMergedMessage( + accumulator: MergedMessageAccumulator, + keptAssistantUuids: Set +): ParsedMessage { + const { + toolCalls: _toolCalls, + toolResults: _toolResults, + sourceToolUseID: _sourceToolUseID, + sourceToolAssistantUUID: _sourceToolAssistantUUID, + toolUseResult: _toolUseResult, + ...base + } = accumulator.message; + + const toolCalls = extractToolCalls(accumulator.content); + const toolResults = extractToolResults(accumulator.content); + const singleToolUseResult = selectSingleToolUseResult(accumulator.toolUseResults); + const derivedToolUseId = + selectSingleValue(accumulator.sourceToolUseIds) ?? + (toolResults.length === 1 ? toolResults[0]?.toolUseId : undefined) ?? + extractToolUseIdFromToolUseResult(singleToolUseResult); + const sourceToolAssistantUUID = selectSingleValue(accumulator.sourceToolAssistantUUIDs); + const preservedSourceToolAssistantUUID = + sourceToolAssistantUUID && keptAssistantUuids.has(sourceToolAssistantUUID) + ? sourceToolAssistantUUID + : undefined; + const toolUseResult = singleToolUseResult; + + return { + ...base, + content: accumulator.content, + toolCalls, + toolResults, + ...(derivedToolUseId ? { sourceToolUseID: derivedToolUseId } : {}), + ...(preservedSourceToolAssistantUUID + ? { sourceToolAssistantUUID: preservedSourceToolAssistantUUID } + : {}), + ...(toolUseResult ? { toolUseResult } : {}), + }; +} + +function mergeMessages( + details: Array<{ filePath: string; filteredMessages: ParsedMessage[] }> +): ParsedMessage[] { + const byMessageKey = new Map(); + let order = 0; + + for (const detail of details) { + for (const message of detail.filteredMessages) { + const key = `${detail.filePath}:${message.uuid}`; + const existing = byMessageKey.get(key); + if (existing) { + updateAccumulator(existing, message); + } else { + byMessageKey.set(key, createAccumulator(message, order)); + order += 1; + } + } + } + + const mergedAccumulators = [...byMessageKey.values()].sort( + (left, right) => left.firstSeenOrder - right.firstSeenOrder + ); + const keptAssistantUuids = new Set( + mergedAccumulators + .filter((entry) => entry.message.type === 'assistant') + .map((entry) => entry.message.uuid) + ); + + return mergedAccumulators.map((entry) => rebuildMergedMessage(entry, keptAssistantUuids)); +} + +function buildSegmentId(participantKey: string, slices: StreamSlice[]): string { + const first = slices[0]; + const last = slices[slices.length - 1]; + return `${participantKey}:${first?.id ?? 'start'}:${last?.id ?? 'end'}`; +} + +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() + ) {} + + async getTaskLogStream(teamName: string, taskId: string): Promise { + if (!isBoardTaskExactLogsReadEnabled()) { + return emptyResponse(); + } + + const records = await this.recordSource.getTaskRecords(teamName, taskId); + if (records.length === 0) { + return emptyResponse(); + } + + const fileVersionsByPath = await getBoardTaskExactLogFileVersions( + records.map((record) => record.source.filePath) + ); + + const candidates = this.summarySelector + .selectSummaries({ + records, + fileVersionsByPath, + }) + .filter((candidate) => candidate.canLoadDetail) + .sort(compareCandidates); + + if (candidates.length === 0) { + return emptyResponse(); + } + + const parsedMessagesByFile = await this.strictParser.parseFiles( + candidates.map((candidate) => candidate.source.filePath) + ); + + const slices: StreamSlice[] = []; + for (const candidate of candidates) { + const detail = this.detailSelector.selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + if (!detail || detail.filteredMessages.length === 0) { + continue; + } + + const filteredMessages = + candidate.anchor.kind === 'tool' + ? pruneToolAnchoredAssistantOutputMessages( + detail.filteredMessages, + candidate.anchor.toolUseId + ) + : detail.filteredMessages; + const sanitizedMessages = sanitizeJsonLikeToolResultPayloads( + filteredMessages, + candidate.canonicalToolName + ); + const prunedMessages = pruneEmptyInternalToolResultMessages(sanitizedMessages); + if (prunedMessages.length === 0) { + continue; + } + + const actor = toStreamActor(detail.actor); + slices.push({ + id: detail.id, + timestamp: detail.timestamp, + filePath: detail.source.filePath, + participantKey: buildParticipantKey(actor), + actor, + actionCategory: candidate.actionCategory, + filteredMessages: prunedMessages, + }); + } + + if (slices.length === 0) { + return emptyResponse(); + } + + const deNoisedSlices = filterReadOnlySlices(slices); + + const namedParticipantSlices = deNoisedSlices.filter((slice) => + hasNamedParticipant(slice.actor) + ); + const visibleSlices = + namedParticipantSlices.length > 0 ? namedParticipantSlices : deNoisedSlices; + + const participantsByKey = new Map(); + const participantOrder: string[] = []; + for (const slice of visibleSlices) { + if (participantsByKey.has(slice.participantKey)) { + continue; + } + participantsByKey.set( + slice.participantKey, + buildParticipant(slice.actor, slice.participantKey) + ); + participantOrder.push(slice.participantKey); + } + + const orderedParticipants = participantOrder + .map((key) => participantsByKey.get(key)) + .filter((participant): participant is BoardTaskLogParticipant => Boolean(participant)) + .sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return participantOrder.indexOf(left.key) - participantOrder.indexOf(right.key); + }); + + const segments: BoardTaskLogSegment[] = []; + let currentSegmentSlices: StreamSlice[] = []; + + const flushSegment = (): void => { + if (currentSegmentSlices.length === 0) return; + const participantKey = currentSegmentSlices[0]!.participantKey; + const actor = currentSegmentSlices[0]!.actor; + const mergedMessages = mergeMessages( + currentSegmentSlices.map((slice) => ({ + filePath: slice.filePath, + filteredMessages: slice.filteredMessages, + })) + ); + const cleanedMessages = pruneEmptyInternalToolResultMessages(mergedMessages); + if (cleanedMessages.length === 0) { + currentSegmentSlices = []; + return; + } + const chunks = this.chunkBuilder.buildBundleChunks(cleanedMessages); + if (chunks.length > 0) { + segments.push({ + id: buildSegmentId(participantKey, currentSegmentSlices), + participantKey, + actor, + startTimestamp: currentSegmentSlices[0]!.timestamp, + endTimestamp: currentSegmentSlices[currentSegmentSlices.length - 1]!.timestamp, + chunks, + }); + } + currentSegmentSlices = []; + }; + + for (const slice of visibleSlices) { + if ( + currentSegmentSlices.length > 0 && + currentSegmentSlices[0]!.participantKey !== slice.participantKey + ) { + flushSegment(); + } + currentSegmentSlices.push(slice); + } + flushSegment(); + + const namedParticipants = orderedParticipants.filter((participant) => !participant.isLead); + const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0]!.key : 'all'; + + return { + participants: orderedParticipants, + defaultFilter, + segments, + }; + } +} diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 45fd3dfe..2d1b451d 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -301,6 +301,18 @@ export const TEAM_GET_MEMBER_LOGS = 'team:getMemberLogs'; /** Get session logs that reference a task */ export const TEAM_GET_LOGS_FOR_TASK = 'team:getLogsForTask'; +/** Get explicit board-task activity derived from transcript metadata */ +export const TEAM_GET_TASK_ACTIVITY = 'team:getTaskActivity'; + +/** Get one task-scoped log stream derived from explicit board-task activity */ +export const TEAM_GET_TASK_LOG_STREAM = 'team:getTaskLogStream'; + +/** Get exact task-log summaries derived from explicit board-task activity records */ +export const TEAM_GET_TASK_EXACT_LOG_SUMMARIES = 'team:getTaskExactLogSummaries'; + +/** Get one exact task-log detail bundle for renderer reuse */ +export const TEAM_GET_TASK_EXACT_LOG_DETAIL = 'team:getTaskExactLogDetail'; + /** Update team config (name, description) */ export const TEAM_UPDATE_CONFIG = 'team:updateConfig'; diff --git a/src/preload/index.ts b/src/preload/index.ts index 55c4e3d1..255b6bbc 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,12 +8,11 @@ import { API_KEYS_SAVE, API_KEYS_STORAGE_STATUS, APP_RELAUNCH, - CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_GET_PROVIDER_STATUS, + CLI_INSTALLER_GET_STATUS, CLI_INSTALLER_INSTALL, CLI_INSTALLER_INVALIDATE_STATUS, CLI_INSTALLER_PROGRESS, - TMUX_GET_STATUS, CONTEXT_CHANGED, CONTEXT_GET_ACTIVE, CONTEXT_LIST, @@ -125,8 +124,13 @@ import { TEAM_GET_DATA, TEAM_GET_DELETED_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, + TEAM_GET_MESSAGES_PAGE, TEAM_GET_PROJECT_BRANCH, TEAM_GET_SAVED_REQUEST, TEAM_GET_TASK_ATTACHMENT, @@ -152,7 +156,6 @@ import { TEAM_RESTORE_TASK, TEAM_SAVE_TASK_ATTACHMENT, TEAM_SEND_MESSAGE, - TEAM_GET_MESSAGES_PAGE, TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_SET_PROJECT_BRANCH_TRACKING, TEAM_SET_TASK_CLARIFICATION, @@ -180,6 +183,7 @@ import { TERMINAL_RESIZE, TERMINAL_SPAWN, TERMINAL_WRITE, + TMUX_GET_STATUS, UPDATER_CHECK, UPDATER_DOWNLOAD, UPDATER_INSTALL, @@ -228,6 +232,10 @@ import type { ApplyReviewRequest, ApplyReviewResult, AttachmentFileData, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, ChangeStats, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -252,6 +260,7 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + MessagesPage, NotificationTrigger, ProjectBranchChangeEvent, RejectResult, @@ -261,7 +270,6 @@ import type { ScheduleRun, SendMessageRequest, SendMessageResult, - MessagesPage, SessionsByIdsOptions, SessionsPaginationOptions, SnippetDiff, @@ -290,10 +298,10 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, + TmuxStatus, ToolApprovalEvent, ToolApprovalFileContent, ToolApprovalSettings, - TmuxStatus, TriggerTestResult, UpdateKanbanPatch, UpdateSchedulePatch, @@ -954,6 +962,41 @@ const electronAPI: ElectronAPI = { options ); }, + getTaskActivity: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_ACTIVITY, + teamName, + taskId + ); + }, + getTaskLogStream: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_LOG_STREAM, + teamName, + taskId + ); + }, + getTaskExactLogSummaries: async (teamName: string, taskId: string) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, + teamName, + taskId + ); + }, + getTaskExactLogDetail: async ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => { + return invokeIpcWithResult( + TEAM_GET_TASK_EXACT_LOG_DETAIL, + teamName, + taskId, + exactLogId, + expectedSourceGeneration + ); + }, getMemberStats: async (teamName: string, memberName: string) => { return invokeIpcWithResult(TEAM_GET_MEMBER_STATS, teamName, memberName); }, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d07a16f4..26ea9ed4 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -9,6 +9,9 @@ import type { AppConfig, AttachmentFileData, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, ClaudeMdFileInfo, ClaudeRootFolderSelection, ClaudeRootInfo, @@ -804,6 +807,26 @@ export class HttpAPIClient implements ElectronAPI { getLogsForTask: async () => { return []; }, + getTaskActivity: async () => { + console.warn('[HttpAPIClient] getTaskActivity is not available in browser mode'); + return []; + }, + getTaskLogStream: async (): Promise => { + console.warn('[HttpAPIClient] getTaskLogStream is not available in browser mode'); + return { + participants: [], + defaultFilter: 'all', + segments: [], + }; + }, + getTaskExactLogSummaries: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogSummaries is not available in browser mode'); + return { items: [] }; + }, + getTaskExactLogDetail: async (): Promise => { + console.warn('[HttpAPIClient] getTaskExactLogDetail is not available in browser mode'); + return { status: 'missing' }; + }, getMemberStats: async () => { console.warn('[HttpAPIClient] getMemberStats is not available in browser mode'); return { diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index 73546360..83d2cedf 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -9,7 +9,7 @@ import { import { CollapsibleTeamSection } from '@renderer/components/team/CollapsibleTeamSection'; import { FileIcon } from '@renderer/components/team/editor/FileIcon'; import { MemberBadge } from '@renderer/components/team/MemberBadge'; -import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; +import { TaskLogsPanel } from '@renderer/components/team/taskLogs/TaskLogsPanel'; import { Badge } from '@renderer/components/ui/badge'; import { Button } from '@renderer/components/ui/button'; import { @@ -1256,29 +1256,8 @@ export const TaskDetailDialog = ({ {variant === 'team' ? ( } - headerExtra={ - logsRefreshing || executionPreviewOnline ? ( - - {executionPreviewOnline ? ( - - - - - ) : null} - {logsRefreshing ? ( - - - Updating... - - ) : null} - - ) : null - } contentClassName="pl-2.5 overflow-visible" headerClassName="-mx-6 w-[calc(100%+3rem)]" headerContentClassName="pl-6" @@ -1286,19 +1265,14 @@ export const TaskDetailDialog = ({ keepMounted >
- diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx new file mode 100644 index 00000000..bb37cc02 --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExactTaskLogCard.tsx @@ -0,0 +1,132 @@ +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { ChevronDown, ChevronRight, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { BoardTaskExactLogSummary } from '@shared/types'; + +export interface ExactTaskLogDetailState { + status: 'idle' | 'loading' | 'ok' | 'missing' | 'error'; + generation?: string; + chunks?: ReturnType; + error?: string; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(summary: BoardTaskExactLogSummary): string { + if (summary.actor.memberName) { + return summary.actor.memberName; + } + if (summary.actor.role === 'lead' || summary.actor.isSidechain === false) { + return 'lead session'; + } + return 'unknown actor'; +} + +function describeSummary(summary: BoardTaskExactLogSummary): string { + return summary.actionLabel; +} + +function anchorKindLabel(summary: BoardTaskExactLogSummary): string { + return summary.anchorKind === 'tool' ? 'tool' : 'message'; +} + +function describeDetailState(state: ExactTaskLogDetailState | undefined): string | null { + if (!state) return null; + if (state.status === 'missing') { + return 'Exact detail is no longer available for this transcript slice.'; + } + if (state.status === 'error') { + return state.error ?? 'Failed to load exact detail.'; + } + return null; +} + +interface ExactTaskLogCardProps { + summary: BoardTaskExactLogSummary; + expanded: boolean; + detailState?: ExactTaskLogDetailState; + onToggle: () => void; +} + +export function ExactTaskLogCard({ + summary, + expanded, + detailState, + onToggle, +}: ExactTaskLogCardProps): React.JSX.Element { + const loadStateText = describeDetailState(detailState); + + return ( +
+ + + {expanded ? ( +
+ {detailState?.status === 'loading' ? ( +
+ + Loading exact task logs... +
+ ) : null} + {detailState?.status === 'ok' && detailState.chunks ? ( +
+ +
+ ) : null} + {detailState?.status !== 'loading' && loadStateText ? ( +
{loadStateText}
+ ) : null} +
+ ) : null} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx new file mode 100644 index 00000000..9d324047 --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExactTaskLogsSection.tsx @@ -0,0 +1,262 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { api } from '@renderer/api'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { AlertCircle, FileText, Loader2 } from 'lucide-react'; + +import { ExactTaskLogCard, type ExactTaskLogDetailState } from './ExactTaskLogCard'; + +import type { BoardTaskExactLogSummary } from '@shared/types'; + +interface ExactTaskLogsSectionProps { + teamName: string; + taskId: string; +} + +export function ExactTaskLogsSection({ + teamName, + taskId, +}: ExactTaskLogsSectionProps): React.JSX.Element { + const [summaries, setSummaries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [expandedId, setExpandedId] = useState(null); + const [detailStates, setDetailStates] = useState>({}); + const latestRequestSeqById = useRef>({}); + + const loadSummaries = useCallback(async (): Promise => { + const result = await api.teams.getTaskExactLogSummaries(teamName, taskId); + const nextItems = [...result.items].sort((left, right) => { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); + }); + setSummaries(nextItems); + return nextItems; + }, [taskId, teamName]); + + useEffect(() => { + let cancelled = false; + + const run = async (): Promise => { + try { + setLoading(true); + setError(null); + setExpandedId(null); + setDetailStates({}); + latestRequestSeqById.current = {}; + const nextItems = await api.teams.getTaskExactLogSummaries(teamName, taskId); + if (cancelled) return; + setSummaries( + [...nextItems.items].sort((left, right) => { + const leftTs = Date.parse(left.timestamp); + const rightTs = Date.parse(right.timestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return rightTs - leftTs; + } + if (left.source.filePath !== right.source.filePath) { + return left.source.filePath.localeCompare(right.source.filePath); + } + if (left.source.sourceOrder !== right.source.sourceOrder) { + return left.source.sourceOrder - right.source.sourceOrder; + } + return left.id.localeCompare(right.id); + }) + ); + } catch (loadError) { + if (!cancelled) { + setError( + loadError instanceof Error ? loadError.message : 'Failed to load exact task logs' + ); + setSummaries([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [taskId, teamName]); + + const fetchDetail = useCallback( + async ( + summary: Extract, + retryOnStale: boolean + ): Promise => { + const nextSeq = (latestRequestSeqById.current[summary.id] ?? 0) + 1; + latestRequestSeqById.current[summary.id] = nextSeq; + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'loading', + generation: summary.sourceGeneration, + }, + })); + + try { + const result = await api.teams.getTaskExactLogDetail( + teamName, + taskId, + summary.id, + summary.sourceGeneration + ); + if (latestRequestSeqById.current[summary.id] !== nextSeq) { + return; + } + + if (result.status === 'stale' && retryOnStale) { + const refreshed = await loadSummaries(); + const refreshedSummary = refreshed.find( + (item): item is Extract => + item.id === summary.id && item.canLoadDetail + ); + if (!refreshedSummary) { + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { status: 'missing' }, + })); + return; + } + await fetchDetail(refreshedSummary, false); + return; + } + + if (result.status === 'ok') { + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'ok', + generation: summary.sourceGeneration, + chunks: asEnhancedChunkArray(result.detail.chunks), + }, + })); + return; + } + + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { status: 'missing', generation: summary.sourceGeneration }, + })); + } catch (detailError) { + if (latestRequestSeqById.current[summary.id] !== nextSeq) { + return; + } + setDetailStates((prev) => ({ + ...prev, + [summary.id]: { + status: 'error', + generation: summary.sourceGeneration, + error: + detailError instanceof Error ? detailError.message : 'Failed to load exact task logs', + }, + })); + } + }, + [loadSummaries, taskId, teamName] + ); + + const handleToggle = useCallback( + async (summary: BoardTaskExactLogSummary): Promise => { + if (!summary.canLoadDetail) { + return; + } + if (expandedId === summary.id) { + setExpandedId(null); + return; + } + setExpandedId(summary.id); + + const existing = detailStates[summary.id]; + if (existing?.generation === summary.sourceGeneration && existing.status !== 'error') { + return; + } + + await fetchDetail(summary, true); + }, + [detailStates, expandedId, fetchDetail] + ); + + const visibleSummaries = useMemo(() => summaries, [summaries]); + + if (loading && visibleSummaries.length === 0) { + return ( +
+
+

+ Exact Task Logs +

+
+
+ + Loading exact task logs... +
+
+ ); + } + + if (error) { + return ( +
+
+

+ Exact Task Logs +

+
+
+ + {error} +
+
+ ); + } + + return ( +
+
+

+ Exact Task Logs +

+
+

+ Exact transcript slices rendered with the same execution-log components used in Logs. +

+ + {visibleSummaries.length === 0 ? ( +
+ + No exact task logs yet +

+ Exact transcript bundles will appear here when explicit task-linked transcript metadata + is available. +

+
+ ) : ( +
+ {visibleSummaries.map((summary) => ( + void handleToggle(summary)} + /> + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx new file mode 100644 index 00000000..283a2f2e --- /dev/null +++ b/src/renderer/components/team/taskLogs/ExecutionSessionsSection.tsx @@ -0,0 +1,48 @@ +import type { ComponentProps } from 'react'; + +import { MemberLogsTab } from '@renderer/components/team/members/MemberLogsTab'; +import { Loader2 } from 'lucide-react'; + +interface ExecutionSessionsSectionProps extends ComponentProps { + isRefreshing?: boolean; + isPreviewOnline?: boolean; +} + +export function ExecutionSessionsSection({ + isRefreshing = false, + isPreviewOnline = false, + ...props +}: ExecutionSessionsSectionProps): React.JSX.Element { + return ( +
+
+

+ Execution Sessions +

+ {isRefreshing || isPreviewOnline ? ( + + {isPreviewOnline ? ( + + + + + ) : null} + {isRefreshing ? ( + + + Updating... + + ) : null} + + ) : null} +
+

+ Legacy session-centric transcript browsing and previews. +

+ +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskActivitySection.tsx b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx new file mode 100644 index 00000000..1e24f039 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskActivitySection.tsx @@ -0,0 +1,211 @@ +import { api } from '@renderer/api'; +import { AlertCircle, Loader2 } from 'lucide-react'; +import { useEffect, useMemo, useState } from 'react'; +import { + describeBoardTaskActivityLabel, + formatBoardTaskActivityTaskLabel, +} from '@shared/utils/boardTaskActivityLabels'; + +import type { BoardTaskActivityEntry, BoardTaskActivityTaskRef } from '@shared/types'; + +interface TaskActivitySectionProps { + teamName: string; + taskId: string; +} + +function formatEntryTime(timestamp: string): string { + const date = new Date(timestamp); + if (Number.isNaN(date.getTime())) { + return '--:--'; + } + return date.toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit', + }); +} + +function formatTaskLabel(task: BoardTaskActivityTaskRef | undefined): string | null { + return formatBoardTaskActivityTaskLabel(task); +} + +function relationshipContextLabel(entry: BoardTaskActivityEntry): string | null { + const peerTaskLabel = formatTaskLabel(entry.action?.peerTask); + if (!peerTaskLabel) return null; + + switch (entry.action?.relationshipPerspective) { + case 'incoming': + return `from ${peerTaskLabel}`; + case 'outgoing': + return `to ${peerTaskLabel}`; + default: + return `with ${peerTaskLabel}`; + } +} + +function describeContext(entry: BoardTaskActivityEntry): string | null { + const parts: string[] = []; + + const relationshipContext = relationshipContextLabel(entry); + if (relationshipContext) { + parts.push(relationshipContext); + } + + if (entry.actorContext.relation === 'other_active_task') { + const activeTaskLabel = formatTaskLabel(entry.actorContext.activeTask); + if (activeTaskLabel) { + parts.push(`while working on ${activeTaskLabel}`); + } else { + parts.push('while another task was active'); + } + } else if (entry.actorContext.relation === 'ambiguous') { + parts.push('while multiple task scopes were active'); + } else if (entry.actorContext.relation === 'idle' && entry.linkKind !== 'execution') { + parts.push('without an active task scope'); + } + + if (entry.task.resolution === 'deleted') { + parts.push('task is deleted'); + } else if (entry.task.resolution === 'ambiguous') { + parts.push('task resolution is ambiguous'); + } else if (entry.task.resolution === 'unresolved') { + parts.push('task could not be resolved'); + } + + return parts.length > 0 ? parts.join(' - ') : null; +} + +function actorLabel(entry: BoardTaskActivityEntry): string { + if (entry.actor.memberName) { + return entry.actor.memberName; + } + if (entry.actor.role === 'lead' || entry.actor.isSidechain === false) { + return 'lead session'; + } + return 'unknown actor'; +} + +function Row({ entry }: { entry: BoardTaskActivityEntry }): React.JSX.Element { + const context = describeContext(entry); + const tone = + entry.task.resolution === 'resolved' + ? 'text-[var(--color-text)]' + : 'text-[var(--color-text-muted)]'; + + return ( +
+
+
+ {formatEntryTime(entry.timestamp)} +
+
+
+ {actorLabel(entry)} + - + {describeBoardTaskActivityLabel(entry)} +
+ {context ? ( +

{context}

+ ) : null} +
+
+
+ ); +} + +export function TaskActivitySection({ + teamName, + taskId, +}: TaskActivitySectionProps): React.JSX.Element { + const [entries, setEntries] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + let cancelled = false; + + const load = async (): Promise => { + try { + if (!cancelled && entries.length === 0) { + setLoading(true); + } + if (!cancelled) { + setError(null); + } + const result = await api.teams.getTaskActivity(teamName, taskId); + if (!cancelled) { + setEntries(result); + } + } catch (loadError) { + if (!cancelled) { + setError(loadError instanceof Error ? loadError.message : 'Failed to load task activity'); + setEntries([]); + } + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void load(); + const intervalId = window.setInterval(() => { + void load(); + }, 8000); + + return () => { + cancelled = true; + window.clearInterval(intervalId); + }; + }, [entries.length, teamName, taskId]); + + const content = useMemo(() => { + if (loading) { + return ( +
+ + Loading task activity... +
+ ); + } + + if (error) { + return ( +
+ + {error} +
+ ); + } + + if (entries.length === 0) { + return ( +

+ No explicit task activity was found in the available transcripts yet. Older or heuristic + session logs may still be available below in Execution Sessions. +

+ ); + } + + return ( +
+ {entries.map((entry) => ( + + ))} +
+ ); + }, [entries, error, loading]); + + return ( +
+
+

+ Task Activity +

+
+

+ Explicit runtime activity linked to this task from transcript metadata. +

+ {content} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx new file mode 100644 index 00000000..4afa2cd5 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -0,0 +1,222 @@ +import { useEffect, useMemo, useState } from 'react'; + +import { api } from '@renderer/api'; +import { MemberExecutionLog } from '@renderer/components/team/members/MemberExecutionLog'; +import { asEnhancedChunkArray } from '@renderer/types/data'; +import { AlertCircle, Clock, FileText, Loader2 } from 'lucide-react'; + +import type { + BoardTaskLogActor, + BoardTaskLogSegment, + BoardTaskLogStreamResponse, +} from '@shared/types'; + +interface TaskLogStreamSectionProps { + teamName: string; + taskId: string; +} + +function formatRelativeTime(isoString: string): string { + const date = new Date(isoString); + const diffMs = Date.now() - date.getTime(); + const diffMin = Math.floor(diffMs / 60_000); + const diffHours = Math.floor(diffMin / 60); + const diffDays = Math.floor(diffHours / 24); + + if (!Number.isFinite(diffMs)) return '--'; + if (diffMin < 1) return 'just now'; + if (diffMin < 60) return `${diffMin}m ago`; + if (diffHours < 24) return `${diffHours}h ago`; + return `${diffDays}d ago`; +} + +function actorLabel(actor: BoardTaskLogActor): string { + if (actor.memberName) { + return actor.memberName; + } + if (actor.role === 'lead' || actor.isSidechain === false) { + return 'lead session'; + } + if (actor.agentId) { + return `member ${actor.agentId.slice(0, 8)}`; + } + return `member session ${actor.sessionId.slice(0, 8)}`; +} + +function normalizeResponse(response: BoardTaskLogStreamResponse): BoardTaskLogStreamResponse { + return { + participants: response.participants, + defaultFilter: response.defaultFilter, + segments: response.segments.map((segment) => ({ + ...segment, + chunks: asEnhancedChunkArray(segment.chunks) ?? [], + })), + }; +} + +function SegmentMarker({ segment }: { segment: BoardTaskLogSegment }): React.JSX.Element { + return ( +
+ + {actorLabel(segment.actor)} + + + + {formatRelativeTime(segment.endTimestamp)} + +
+ ); +} + +function SegmentBlock({ + segment, + showHeader, +}: { + segment: BoardTaskLogSegment; + showHeader: boolean; +}): React.JSX.Element { + return ( +
+ {showHeader ? : null} + +
+ ); +} + +export function TaskLogStreamSection({ + teamName, + taskId, +}: TaskLogStreamSectionProps): React.JSX.Element { + const [stream, setStream] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [selectedParticipantKey, setSelectedParticipantKey] = useState<'all' | string>('all'); + + useEffect(() => { + let cancelled = false; + + const run = async (): Promise => { + try { + setLoading(true); + setError(null); + const response = normalizeResponse(await api.teams.getTaskLogStream(teamName, taskId)); + if (cancelled) return; + setStream(response); + setSelectedParticipantKey(response.defaultFilter); + } catch (loadError) { + if (cancelled) return; + setError(loadError instanceof Error ? loadError.message : 'Failed to load task log stream'); + setStream(null); + } finally { + if (!cancelled) { + setLoading(false); + } + } + }; + + void run(); + return () => { + cancelled = true; + }; + }, [taskId, teamName]); + + const participants = stream?.participants ?? []; + const showChips = participants.length > 1; + const visibleSegments = useMemo(() => { + const source = stream?.segments ?? []; + const filtered = + selectedParticipantKey === 'all' + ? source + : source.filter((segment) => segment.participantKey === selectedParticipantKey); + return [...filtered].reverse(); + }, [selectedParticipantKey, stream?.segments]); + + const showSegmentHeaders = + participants.length > 1 || (selectedParticipantKey !== 'all' && visibleSegments.length > 1); + + if (loading) { + return ( +
+

+ Task Log Stream +

+
+ + Loading task log stream... +
+
+ ); + } + + if (error) { + return ( +
+

+ Task Log Stream +

+
+ + {error} +
+
+ ); + } + + return ( +
+

+ Task Log Stream +

+

+ Task-scoped transcript logs rendered with the same execution-log components used in Logs. +

+ + {showChips ? ( +
+ + {participants.map((participant) => ( + + ))} +
+ ) : null} + + {visibleSegments.length === 0 ? ( +
+ + No task log stream yet +

+ Task-linked transcript logs will appear here when explicit task-linked transcript + metadata is available. +

+
+ ) : ( +
+ {visibleSegments.map((segment) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx new file mode 100644 index 00000000..913e5537 --- /dev/null +++ b/src/renderer/components/team/taskLogs/TaskLogsPanel.tsx @@ -0,0 +1,55 @@ +import { ExecutionSessionsSection } from './ExecutionSessionsSection'; +import { TaskActivitySection } from './TaskActivitySection'; +import { TaskLogStreamSection } from './TaskLogStreamSection'; +import { isBoardTaskActivityUiEnabled, isBoardTaskExactLogsUiEnabled } from './featureGates'; + +import type { TeamTaskWithKanban } from '@shared/types'; + +interface TaskLogsPanelProps { + teamName: string; + task: TeamTaskWithKanban; + taskSince?: string; + isExecutionRefreshing?: boolean; + isExecutionPreviewOnline?: boolean; + onRefreshingChange?: (isRefreshing: boolean) => void; + showSubagentPreview?: boolean; + showLeadPreview?: boolean; + onPreviewOnlineChange?: (isOnline: boolean) => void; +} + +export function TaskLogsPanel({ + teamName, + task, + taskSince, + isExecutionRefreshing = false, + isExecutionPreviewOnline = false, + onRefreshingChange, + showSubagentPreview = false, + showLeadPreview = false, + onPreviewOnlineChange, +}: TaskLogsPanelProps): React.JSX.Element { + return ( +
+ {isBoardTaskActivityUiEnabled() ? ( + + ) : null} + {isBoardTaskExactLogsUiEnabled() ? ( + + ) : null} + +
+ ); +} diff --git a/src/renderer/components/team/taskLogs/featureGates.ts b/src/renderer/components/team/taskLogs/featureGates.ts new file mode 100644 index 00000000..f293958a --- /dev/null +++ b/src/renderer/components/team/taskLogs/featureGates.ts @@ -0,0 +1,22 @@ +function readEnabledFlag(value: unknown, defaultValue: boolean): boolean { + if (typeof value !== 'string') { + return defaultValue; + } + + const normalized = value.trim().toLowerCase(); + if (normalized === '0' || normalized === 'false' || normalized === 'off' || normalized === 'no') { + return false; + } + if (normalized === '1' || normalized === 'true' || normalized === 'on' || normalized === 'yes') { + return true; + } + return defaultValue; +} + +export function isBoardTaskActivityUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_ACTIVITY_UI_ENABLED, true); +} + +export function isBoardTaskExactLogsUiEnabled(): boolean { + return readEnabledFlag(import.meta.env.VITE_BOARD_TASK_EXACT_LOGS_UI_ENABLED, true); +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index 9413db3f..8eb817cf 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -38,6 +38,10 @@ import type { } from './schedule'; import type { AddMemberRequest, + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, AddTaskCommentRequest, AttachmentFileData, CreateTaskRequest, @@ -51,11 +55,11 @@ import type { MemberFullStats, MemberLogSummary, MemberSpawnStatusesSnapshot, + MessagesPage, ProjectBranchChangeEvent, ReplaceMembersRequest, SendMessageRequest, SendMessageResult, - MessagesPage, TaskAttachmentMeta, TaskChangePresenceState, TaskComment, @@ -477,6 +481,18 @@ export interface TeamsAPI { since?: string; } ) => Promise; + getTaskActivity: (teamName: string, taskId: string) => Promise; + getTaskLogStream: (teamName: string, taskId: string) => Promise; + getTaskExactLogSummaries: ( + teamName: string, + taskId: string + ) => Promise; + getTaskExactLogDetail: ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => Promise; getMemberStats: (teamName: string, memberName: string) => Promise; launchTeam: (request: TeamLaunchRequest) => Promise; getAllTasks: () => Promise; diff --git a/src/shared/utils/boardTaskActivityLabels.ts b/src/shared/utils/boardTaskActivityLabels.ts new file mode 100644 index 00000000..36f99fdd --- /dev/null +++ b/src/shared/utils/boardTaskActivityLabels.ts @@ -0,0 +1,128 @@ +import type { + BoardTaskActivityAction, + BoardTaskActivityLinkKind, + BoardTaskActivityTaskRef, +} from '../types/team'; + +interface BoardTaskActivityLabelInput { + action?: BoardTaskActivityAction; + linkKind: BoardTaskActivityLinkKind; +} + +export function formatBoardTaskActivityTaskLabel( + task: BoardTaskActivityTaskRef | undefined +): string | null { + if (!task) return null; + if (task.taskRef) { + return `#${task.taskRef.displayId}`; + } + if (task.locator.ref) { + return `#${task.locator.ref}`; + } + return null; +} + +function describeRelationshipAction( + action: BoardTaskActivityAction | undefined, + verb: 'link' | 'unlink' +): string { + const peerTaskLabel = formatBoardTaskActivityTaskLabel(action?.peerTask); + const relationship = action?.details?.relationship; + + if (relationship === 'related' && peerTaskLabel) { + return verb === 'link' + ? `Linked related task ${peerTaskLabel}` + : `Removed related link with ${peerTaskLabel}`; + } + + if (action?.relationshipPerspective === 'incoming' && peerTaskLabel) { + return verb === 'link' + ? `Linked blocked by ${peerTaskLabel}` + : `Removed blocked-by link from ${peerTaskLabel}`; + } + + if (action?.relationshipPerspective === 'outgoing' && peerTaskLabel) { + return verb === 'link' + ? `Linked blocks ${peerTaskLabel}` + : `Removed blocks link to ${peerTaskLabel}`; + } + + if (relationship) { + return verb === 'link' ? `Linked task as ${relationship}` : `Removed ${relationship} link`; + } + + return verb === 'link' ? 'Linked task' : 'Removed task link'; +} + +export function describeBoardTaskActivityLabel(input: BoardTaskActivityLabelInput): string { + const toolName = input.action?.canonicalToolName; + switch (toolName) { + case 'task_start': + return 'Started work'; + case 'task_complete': + return 'Completed task'; + case 'task_set_status': + return input.action?.details?.status + ? `Set status to ${input.action.details.status}` + : 'Updated task status'; + case 'review_start': + return 'Started review'; + case 'review_approve': + return 'Approved review'; + case 'review_request_changes': + return 'Requested changes'; + case 'review_request': + return input.action?.details?.reviewer + ? `Requested review from ${input.action.details.reviewer}` + : 'Requested review'; + case 'task_add_comment': + return 'Added a comment'; + case 'task_attach_file': + return input.action?.details?.filename + ? `Attached ${input.action.details.filename}` + : 'Attached a file'; + case 'task_attach_comment_file': + return input.action?.details?.filename + ? `Attached ${input.action.details.filename} to a comment` + : 'Attached a file to a comment'; + case 'task_get': + return 'Viewed task'; + case 'task_get_comment': + return input.action?.details?.commentId + ? `Viewed comment ${input.action.details.commentId}` + : 'Viewed comment'; + case 'task_link': + return describeRelationshipAction(input.action, 'link'); + case 'task_unlink': + return describeRelationshipAction(input.action, 'unlink'); + case 'task_set_clarification': + if ( + input.action?.details?.clarification === 'lead' || + input.action?.details?.clarification === 'user' + ) { + return `Set clarification to ${input.action.details.clarification}`; + } + if (input.action?.details && 'clarification' in input.action.details) { + return 'Cleared clarification'; + } + return 'Updated clarification'; + case 'task_set_owner': + if (typeof input.action?.details?.owner === 'string' && input.action.details.owner.trim()) { + return `Assigned owner to ${input.action.details.owner}`; + } + if (input.action?.details && 'owner' in input.action.details) { + return 'Cleared owner'; + } + return 'Updated owner'; + case 'kanban_set_column': + return 'Updated column'; + default: + if (input.linkKind === 'execution') { + return 'Worked on task'; + } + if (input.linkKind === 'lifecycle') { + return 'Updated task lifecycle'; + } + return 'Performed a related board action'; + } +} diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index b102167b..13353fe1 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -1,6 +1,14 @@ import * as os from 'os'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import type { InboxMessage, TeamCreateRequest, TeamProvisioningProgress } from '@shared/types/team'; +import type { + BoardTaskActivityEntry, + BoardTaskLogStreamResponse, + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, + InboxMessage, + TeamCreateRequest, + TeamProvisioningProgress, +} from '@shared/types/team'; vi.mock('electron', () => ({ app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp') }, @@ -64,6 +72,10 @@ import { TEAM_SET_CHANGE_PRESENCE_TRACKING, TEAM_GET_ALL_TASKS, TEAM_GET_LOGS_FOR_TASK, + TEAM_GET_TASK_ACTIVITY, + TEAM_GET_TASK_LOG_STREAM, + TEAM_GET_TASK_EXACT_LOG_DETAIL, + TEAM_GET_TASK_EXACT_LOG_SUMMARIES, TEAM_GET_MEMBER_LOGS, TEAM_GET_MEMBER_STATS, TEAM_START_TASK, @@ -186,6 +198,25 @@ describe('ipc teams handlers', () => { getLeadActivityState: vi.fn(() => 'idle'), stopTeam: vi.fn(() => undefined), }; + const boardTaskActivityService = { + getTaskActivity: vi.fn<() => Promise>(async () => []), + }; + const boardTaskLogStreamService = { + getTaskLogStream: + vi.fn<() => Promise>(async () => ({ + participants: [], + defaultFilter: 'all', + segments: [], + })), + }; + const boardTaskExactLogsService = { + getTaskExactLogSummaries: + vi.fn<() => Promise>(async () => ({ items: [] })), + }; + const boardTaskExactLogDetailService = { + getTaskExactLogDetail: + vi.fn<() => Promise>(async () => ({ status: 'missing' })), + }; beforeEach(() => { handlers.clear(); @@ -195,7 +226,19 @@ describe('ipc teams handlers', () => { mockTeamDataWorkerClient.isAvailable.mockReturnValue(false); mockTeamDataWorkerClient.getTeamData.mockReset(); mockTeamDataWorkerClient.findLogsForTask.mockReset(); - initializeTeamHandlers(service as never, provisioningService as never); + initializeTeamHandlers( + service as never, + provisioningService as never, + undefined, + undefined, + undefined, + undefined, + undefined, + boardTaskActivityService as never, + boardTaskLogStreamService as never, + boardTaskExactLogsService as never, + boardTaskExactLogDetailService as never, + ); registerTeamHandlers(ipcMain as never); }); @@ -224,6 +267,10 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(true); expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_SUMMARIES)).toBe(true); + expect(handlers.has(TEAM_GET_TASK_EXACT_LOG_DETAIL)).toBe(true); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(true); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(true); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(true); @@ -279,6 +326,149 @@ describe('ipc teams handlers', () => { expect(service.getTaskChangePresence).toHaveBeenCalledWith('my-team'); }); + it('returns explicit exact task-log summaries for a task', async () => { + boardTaskExactLogsService.getTaskExactLogSummaries.mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }, + ], + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_SUMMARIES); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + )) as { + success: boolean; + data?: BoardTaskExactLogSummariesResponse; + }; + + expect(result.success).toBe(true); + expect(result.data?.items).toHaveLength(1); + expect(boardTaskExactLogsService.getTaskExactLogSummaries).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + ); + }); + + it('returns one task log stream for a task', async () => { + boardTaskLogStreamService.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'all', + segments: [], + }); + + const handler = handlers.get(TEAM_GET_TASK_LOG_STREAM); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + )) as { + success: boolean; + data?: BoardTaskLogStreamResponse; + }; + + expect(result.success).toBe(true); + expect(result.data?.participants).toHaveLength(1); + expect(boardTaskLogStreamService.getTaskLogStream).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000' + ); + }); + + it('returns exact task-log detail for a task bundle', async () => { + boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({ + status: 'ok', + detail: { + id: 'tool:/tmp/task.jsonl:tool-1', + chunks: [], + }, + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + )) as { + success: boolean; + data?: BoardTaskExactLogDetailResult; + }; + + expect(result.success).toBe(true); + expect(result.data?.status).toBe('ok'); + expect(boardTaskExactLogDetailService.getTaskExactLogDetail).toHaveBeenCalledWith( + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + ); + }); + + it('returns exact task-log detail stale status without rewriting the service result', async () => { + boardTaskExactLogDetailService.getTaskExactLogDetail.mockResolvedValueOnce({ + status: 'stale', + }); + + const handler = handlers.get(TEAM_GET_TASK_EXACT_LOG_DETAIL); + expect(handler).toBeDefined(); + + const result = (await handler!( + {} as never, + 'my-team', + '123e4567-e89b-12d3-a456-426614174000', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-2' + )) as { + success: boolean; + data?: BoardTaskExactLogDetailResult; + }; + + expect(result).toEqual({ + success: true, + data: { status: 'stale' }, + }); + }); + it('returns success false on invalid sendMessage args', async () => { const sendHandler = handlers.get(TEAM_SEND_MESSAGE); expect(sendHandler).toBeDefined(); @@ -893,6 +1083,8 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_CREATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_LOGS)).toBe(false); expect(handlers.has(TEAM_GET_LOGS_FOR_TASK)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_ACTIVITY)).toBe(false); + expect(handlers.has(TEAM_GET_TASK_LOG_STREAM)).toBe(false); expect(handlers.has(TEAM_GET_MEMBER_STATS)).toBe(false); expect(handlers.has(TEAM_UPDATE_CONFIG)).toBe(false); expect(handlers.has(TEAM_GET_ALL_TASKS)).toBe(false); @@ -922,6 +1114,46 @@ describe('ipc teams handlers', () => { expect(handlers.has(TEAM_DELETE_TASK_ATTACHMENT)).toBe(false); }); + it('returns explicit task activity rows', async () => { + const handler = handlers.get(TEAM_GET_TASK_ACTIVITY); + expect(handler).toBeDefined(); + + const activityRows: BoardTaskActivityEntry[] = [ + { + id: 'activity-1', + timestamp: '2026-04-12T10:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display' }, + resolution: 'resolved', + }, + linkKind: 'lifecycle', + targetRole: 'subject', + actor: { + role: 'lead', + sessionId: 'session-1', + isSidechain: false, + }, + actorContext: { + relation: 'idle', + }, + source: { + messageUuid: 'message-1', + filePath: '/tmp/transcript.jsonl', + sourceOrder: 1, + }, + }, + ]; + boardTaskActivityService.getTaskActivity.mockResolvedValueOnce(activityRows); + + const result = (await handler!({} as never, 'my-team', 'task-1')) as { + success: boolean; + data: typeof activityRows; + }; + + expect(result).toEqual({ success: true, data: activityRows }); + expect(boardTaskActivityService.getTaskActivity).toHaveBeenCalledWith('my-team', 'task-1'); + }); + describe('addTaskRelationship', () => { it('calls service on valid input', async () => { const handler = handlers.get(TEAM_ADD_TASK_RELATIONSHIP)!; diff --git a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts new file mode 100644 index 00000000..edc2faae --- /dev/null +++ b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts @@ -0,0 +1,427 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskActivityEntryBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityEntryBuilder'; + +import type { TeamTask } from '../../../../src/shared/types/team'; +import type { RawTaskActivityMessage } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +function makeTask(task: Partial & Pick): TeamTask { + return { + displayId: task.displayId ?? task.id.slice(0, 8), + createdAt: '2026-04-12T10:00:00.000Z', + updatedAt: '2026-04-12T10:00:00.000Z', + ...task, + }; +} + +describe('BoardTaskActivityEntryBuilder', () => { + it('builds same-task execution rows and external board actions', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + const taskB = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174001', + displayId: 'efgh5678', + subject: 'Task B', + status: 'pending', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/a.jsonl', + uuid: 'msg-1', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + { + filePath: '/tmp/b.jsonl', + uuid: 'msg-2', + timestamp: '2026-04-12T10:01:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 2, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-2', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { + relation: 'other_active_task', + activeTask: { ref: 'efgh5678', refKind: 'display', canonicalId: taskB.id }, + activePhase: 'work', + activeExecutionSeq: 2, + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, taskB], + messages, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.linkKind).toBe('execution'); + expect(entries[1]?.actorContext.relation).toBe('other_active_task'); + expect(entries[1]?.action?.canonicalToolName).toBe('task_add_comment'); + expect(entries[1]?.action?.category).toBe('comment'); + expect(entries[1]?.action?.details?.commentId).toBe('comment-1'); + }); + + it('marks display-id collisions as ambiguous instead of guessing', () => { + const liveTask = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Live task', + status: 'in_progress', + }); + const deletedTask = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174099', + displayId: 'abcd1234', + subject: 'Deleted task', + status: 'deleted', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/a.jsonl', + uuid: 'msg-1', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: liveTask, + tasks: [liveTask, deletedTask], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.task.resolution).toBe('ambiguous'); + }); + + it('preserves deleted peer tasks on relationship rows', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + const deletedPeer = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174002', + displayId: 'ijkl9012', + subject: 'Task B', + status: 'deleted', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/relationships.jsonl', + uuid: 'msg-3', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'lead', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-3', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + task: { ref: 'ijkl9012', refKind: 'display', canonicalId: deletedPeer.id }, + targetRole: 'related', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_link', + input: { relationship: 'related' }, + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, deletedPeer], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.action?.peerTask?.resolution).toBe('deleted'); + expect(entries[0]?.action?.details?.relationship).toBe('related'); + expect(entries[0]?.action?.category).toBe('relationship'); + expect(entries[0]?.action?.relationshipPerspective).toBe('symmetric'); + }); + + it('resolves display locators case-insensitively and canonical-like unknown refs safely', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/case.jsonl', + uuid: 'msg-4', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'ABCD1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + task: { ref: taskA.id, refKind: 'unknown' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(2); + expect(entries[0]?.task.resolution).toBe('resolved'); + expect(entries[1]?.task.resolution).toBe('resolved'); + }); + + it('marks main-session actor without explicit name as unknown instead of forcing lead', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/unknown-actor.jsonl', + uuid: 'msg-5', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.actor.role).toBe('unknown'); + }); + + it('never joins action payloads onto execution rows', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/execution-malformed.jsonl', + uuid: 'msg-6', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentId: 'agent-a', + agentName: 'alice', + isSidechain: true, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_start', + }, + ], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.linkKind).toBe('execution'); + expect(entries[0]?.action).toBeUndefined(); + }); + + it('derives relationship perspective from target role', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174010', + displayId: 'taska010', + subject: 'Task A', + status: 'in_progress', + }); + const taskB = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174011', + displayId: 'taskb011', + subject: 'Task B', + status: 'pending', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/relationship-perspective.jsonl', + uuid: 'msg-7', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'lead', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'tool-7', + task: { ref: 'taska010', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-7', + task: { ref: 'taskb011', refKind: 'display', canonicalId: taskB.id }, + targetRole: 'related', + linkKind: 'board_action', + actorContext: { relation: 'idle' }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'tool-7', + canonicalToolName: 'task_link', + input: { relationship: 'blocked-by' }, + }, + ], + }, + ]; + + const builder = new BoardTaskActivityEntryBuilder(); + const entriesForTaskA = builder.buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA, taskB], + messages, + }); + const entriesForTaskB = builder.buildForTask({ + teamName: 'demo', + targetTask: taskB, + tasks: [taskA, taskB], + messages, + }); + + expect(entriesForTaskA).toHaveLength(1); + expect(entriesForTaskA[0]?.action?.relationshipPerspective).toBe('incoming'); + expect(entriesForTaskA[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskB.id); + + expect(entriesForTaskB).toHaveLength(1); + expect(entriesForTaskB[0]?.action?.relationshipPerspective).toBe('outgoing'); + expect(entriesForTaskB[0]?.action?.peerTask?.taskRef?.taskId).toBe(taskA.id); + }); +}); diff --git a/test/main/services/team/BoardTaskActivityRecordSource.test.ts b/test/main/services/team/BoardTaskActivityRecordSource.test.ts new file mode 100644 index 00000000..af672611 --- /dev/null +++ b/test/main/services/team/BoardTaskActivityRecordSource.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource'; + +describe('BoardTaskActivityRecordSource', () => { + it('uses active and deleted tasks together when building explicit task records', async () => { + const targetTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'A', + status: 'pending', + }; + const deletedTask = { + id: 'task-b', + displayId: 'deadbeef', + subject: 'B', + status: 'deleted', + }; + const transcriptFiles = ['/tmp/a.jsonl']; + const rawMessages = [{ uuid: 'm1' }]; + const builtRecords = [{ id: 'r1' }]; + + const locator = { + listTranscriptFiles: vi.fn(async () => transcriptFiles), + }; + const taskReader = { + getTasks: vi.fn(async () => [targetTask]), + getDeletedTasks: vi.fn(async () => [deletedTask]), + }; + const transcriptReader = { + readFiles: vi.fn(async () => rawMessages), + }; + const recordBuilder = { + buildForTask: vi.fn(() => builtRecords), + }; + + const source = new BoardTaskActivityRecordSource( + locator as never, + taskReader as never, + transcriptReader as never, + recordBuilder as never, + ); + + const result = await source.getTaskRecords('demo', 'task-a'); + + expect(result).toBe(builtRecords); + expect(locator.listTranscriptFiles).toHaveBeenCalledWith('demo'); + expect(transcriptReader.readFiles).toHaveBeenCalledWith(transcriptFiles); + expect(recordBuilder.buildForTask).toHaveBeenCalledWith({ + teamName: 'demo', + targetTask, + tasks: [targetTask, deletedTask], + messages: rawMessages, + }); + }); + + it('returns empty when the target task is unknown', async () => { + const locator = { + listTranscriptFiles: vi.fn(async () => ['/tmp/a.jsonl']), + }; + const taskReader = { + getTasks: vi.fn(async () => []), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptReader = { + readFiles: vi.fn(async () => [{ uuid: 'm1' }]), + }; + const recordBuilder = { + buildForTask: vi.fn(() => [{ id: 'r1' }]), + }; + + const source = new BoardTaskActivityRecordSource( + locator as never, + taskReader as never, + transcriptReader as never, + recordBuilder as never, + ); + + await expect(source.getTaskRecords('demo', 'task-missing')).resolves.toEqual([]); + expect(recordBuilder.buildForTask).not.toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts new file mode 100644 index 00000000..0932abcc --- /dev/null +++ b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts @@ -0,0 +1,67 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +const tempPaths: string[] = []; + +async function createTempTranscript(lines: unknown[]): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'board-task-activity-')); + const filePath = path.join(dir, 'transcript.jsonl'); + tempPaths.push(dir); + await fs.writeFile( + filePath, + lines.map(line => JSON.stringify(line)).join('\n'), + 'utf8', + ); + return filePath; +} + +afterEach(async () => { + await Promise.all( + tempPaths.splice(0).map(dir => fs.rm(dir, { recursive: true, force: true })), + ); +}); + +describe('BoardTaskActivityTranscriptReader', () => { + it('skips transcript rows without a stable timestamp', async () => { + const filePath = await createTempTranscript([ + { + uuid: 'missing-timestamp', + sessionId: 'session-1', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + }, + { + uuid: 'valid-row', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ], + }, + ]); + + const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]); + + expect(rows).toHaveLength(1); + expect(rows[0]?.uuid).toBe('valid-row'); + expect(rows[0]?.timestamp).toBe('2026-04-12T10:00:00.000Z'); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts b/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts new file mode 100644 index 00000000..f3425aef --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogChunkBuilder.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogChunkBuilder } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogChunkBuilder'; + +import type { EnhancedChunk, ParsedMessage } from '../../../../src/main/types'; + +describe('BoardTaskExactLogChunkBuilder', () => { + it('delegates to ChunkBuilder with includeSidechain enabled', () => { + const buildChunks = vi.fn<() => EnhancedChunk[]>(() => []); + const messages = [{ uuid: 'm1' }] as unknown as ParsedMessage[]; + + const builder = new BoardTaskExactLogChunkBuilder({ buildChunks } as never); + const result = builder.buildBundleChunks(messages); + + expect(result).toEqual([]); + expect(buildChunks).toHaveBeenCalledWith(messages, [], { includeSidechain: true }); + }); + + it('does not crash on a minimal assistant-only bundle', () => { + const messages: ParsedMessage[] = [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T18:00:00.000Z'), + role: 'assistant', + content: [{ type: 'text', text: 'done' } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ]; + + const chunks = new BoardTaskExactLogChunkBuilder().buildBundleChunks(messages); + + expect(chunks.length).toBeGreaterThan(0); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts new file mode 100644 index 00000000..a9c0b1a9 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts @@ -0,0 +1,305 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskExactLogDetailSelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord(): BoardTaskActivityRecord { + return { + id: 'record-1', + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + }; +} + +function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate { + return { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: records[0]!.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + records, + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +describe('BoardTaskExactLogDetailSelector', () => { + it('keeps the matched tool flow, preserves anchor output, and deduplicates assistant streaming rows anchor-aware', () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-0', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'thinking', thinking: 'draft' } as never, + { type: 'text', text: 'old tool draft' } as never, + { type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'assistant', + content: [ + { type: 'text', text: 'stream tail without anchor tool call' } as never, + { type: 'tool_use', id: 'tool-2', name: 'task_get', input: { taskId: 'y' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never, + { type: 'tool_result', tool_use_id: 'tool-2', content: 'ignore' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-1', + toolUseResult: { output: 'kept' }, + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-2', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:03.000Z'), + role: 'assistant', + content: [{ type: 'text', text: 'comment saved' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + requestId: 'req-2', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(3); + expect(detail?.filteredMessages[0]?.uuid).toBe('assistant-0'); + expect(detail?.filteredMessages[1]?.uuid).toBe('user-1'); + expect(detail?.filteredMessages[2]?.uuid).toBe('assistant-2'); + expect(detail?.filteredMessages[0]?.toolCalls).toHaveLength(1); + expect(detail?.filteredMessages[1]?.toolResults).toHaveLength(1); + expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({ output: 'kept' }); + expect(detail?.filteredMessages[1]?.sourceToolAssistantUUID).toBeUndefined(); + expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1'); + }); + + it('drops stale derived tool metadata when a message-linked row survives filtering', () => { + const record = { + ...makeRecord(), + id: 'record-message-1', + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + sourceOrder: 2, + }, + action: undefined, + } satisfies BoardTaskActivityRecord; + const candidate: BoardTaskExactLogBundleCandidate = { + id: 'message:/tmp/task.jsonl:user-2', + timestamp: '2026-04-12T16:01:00.000Z', + actor: record.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + sourceOrder: 2, + }, + records: [record], + anchor: { + kind: 'message', + filePath: '/tmp/task.jsonl', + messageUuid: 'user-2', + }, + actionLabel: 'Worked on task', + linkKinds: ['execution'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-2', + }; + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'user-2', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:01:00.000Z'), + role: 'user', + content: [ + { type: 'text', text: 'status update' } as never, + { type: 'tool_result', tool_use_id: 'other-tool', content: 'stale tool result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'other-tool', + sourceToolAssistantUUID: 'assistant-other', + toolUseResult: { output: 'stale' }, + requestId: 'req-2', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records: [record], + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(1); + expect(detail?.filteredMessages[0]?.content).toEqual([{ type: 'text', text: 'status update' }]); + expect(detail?.filteredMessages[0]?.toolResults).toEqual([]); + expect(detail?.filteredMessages[0]?.sourceToolUseID).toBeUndefined(); + expect(detail?.filteredMessages[0]?.sourceToolAssistantUUID).toBeUndefined(); + expect(detail?.filteredMessages[0]?.toolUseResult).toBeUndefined(); + }); + + it('preserves toolUseResult for a matched tool_result even when sourceToolUseID is absent', () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: { taskId: 'x' } } as never, + ], + toolCalls: [], + toolResults: [], + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: null, + type: 'user', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never, + ], + toolCalls: [], + toolResults: [], + toolUseResult: { + toolUseId: 'tool-1', + content: 'ok', + }, + requestId: 'req-1', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records, + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages).toHaveLength(2); + expect(detail?.filteredMessages[1]?.sourceToolUseID).toBe('tool-1'); + expect(detail?.filteredMessages[1]?.toolUseResult).toEqual({ + toolUseId: 'tool-1', + content: 'ok', + }); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogDetailService.test.ts b/test/main/services/team/BoardTaskExactLogDetailService.test.ts new file mode 100644 index 00000000..c7ee35f6 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogDetailService.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogDetailService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailService'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { + BoardTaskExactLogBundleCandidate, + BoardTaskExactLogDetailCandidate, +} from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord(): BoardTaskActivityRecord { + return { + id: 'record-1', + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + }; +} + +function makeCandidate(records: BoardTaskActivityRecord[]): BoardTaskExactLogBundleCandidate { + return { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: records[0]!.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + records, + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +describe('BoardTaskExactLogDetailService', () => { + it('returns missing when the exact-log read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { getTaskRecords: vi.fn(async () => []) }; + const service = new BoardTaskExactLogDetailService( + recordSource as never, + { selectSummaries: vi.fn() } as never, + { parseFiles: vi.fn() } as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + await expect( + service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-1') + ).resolves.toEqual({ status: 'missing' }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + vi.unstubAllEnvs(); + }); + + it('returns stale when the expected source generation no longer matches', async () => { + const records = [makeRecord()]; + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [makeCandidate(records)]), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + { parseFiles: vi.fn() } as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', 'tool:/tmp/task.jsonl:tool-1', 'gen-old'); + + expect(result).toEqual({ status: 'stale' }); + }); + + it('returns ok when a matching detail bundle is reconstructed', async () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const detailCandidate: BoardTaskExactLogDetailCandidate = { + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records, + filteredMessages: [], + }; + + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => detailCandidate), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn(() => []), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + chunkBuilder as never + ); + + const result = await service.getTaskExactLogDetail( + 'demo', + 'task-a', + candidate.id, + 'gen-1' + ); + + expect(result).toEqual({ + status: 'ok', + detail: { + id: candidate.id, + chunks: [], + }, + }); + }); + + it('returns missing for non-expandable summaries without parsing transcript content', async () => { + const records = [makeRecord()]; + const nonExpandableCandidate: BoardTaskExactLogBundleCandidate = { + ...makeCandidate(records), + canLoadDetail: false, + }; + const recordSource = { getTaskRecords: vi.fn(async () => records) }; + const summarySelector = { + selectSummaries: vi.fn(() => [nonExpandableCandidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map()), + }; + + const service = new BoardTaskExactLogDetailService( + recordSource as never, + summarySelector as never, + strictParser as never, + { selectDetail: vi.fn() } as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', nonExpandableCandidate.id, 'gen-1'); + + expect(result).toEqual({ status: 'missing' }); + expect(strictParser.parseFiles).not.toHaveBeenCalled(); + }); + + it('returns missing when strict detail reconstruction fails for malformed transcript data', async () => { + const records = [makeRecord()]; + const candidate = makeCandidate(records); + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => null), + }; + + const service = new BoardTaskExactLogDetailService( + { getTaskRecords: vi.fn(async () => records) } as never, + { selectSummaries: vi.fn(() => [candidate]) } as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks: vi.fn() } as never + ); + + const result = await service.getTaskExactLogDetail('demo', 'task-a', candidate.id, 'gen-1'); + + expect(result).toEqual({ status: 'missing' }); + expect(strictParser.parseFiles).toHaveBeenCalledWith(['/tmp/task.jsonl']); + expect(detailSelector.selectDetail).toHaveBeenCalled(); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts new file mode 100644 index 00000000..de8cb2ac --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts @@ -0,0 +1,47 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { BoardTaskExactLogStrictParser } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser'; + +const tempDirs: string[] = []; + +afterEach(async () => { + await Promise.all( + tempDirs.splice(0, tempDirs.length).map(async (dirPath) => { + await fs.rm(dirPath, { recursive: true, force: true }); + }), + ); +}); + +describe('BoardTaskExactLogStrictParser', () => { + it('drops malformed timestamp rows instead of assigning them synthetic time', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + uuid: 'bad-ts', + type: 'assistant', + timestamp: 'not-a-real-date', + message: { role: 'assistant', content: 'bad row' }, + }), + JSON.stringify({ + uuid: 'good-ts', + type: 'assistant', + timestamp: '2026-04-12T18:00:00.000Z', + message: { role: 'assistant', content: 'good row' }, + }), + ].join('\n'), + 'utf8', + ); + + const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]); + + expect(parsed.get(filePath)?.map((message) => message.uuid)).toEqual(['good-ts']); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts b/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts new file mode 100644 index 00000000..58194883 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogSummarySelector.test.ts @@ -0,0 +1,145 @@ +import { describe, expect, it } from 'vitest'; + +import { BoardTaskExactLogSummarySelector } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogSummarySelector'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; + +function makeRecord( + id: string, + overrides: Partial = {} +): BoardTaskActivityRecord { + return { + id, + timestamp: '2026-04-12T16:00:00.000Z', + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-1', + category: 'comment', + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + sourceOrder: 1, + }, + ...overrides, + }; +} + +describe('BoardTaskExactLogSummarySelector', () => { + it('prefers tool anchors over message anchors within one message group', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const records = [ + makeRecord('r1', { source: { filePath: '/tmp/task.jsonl', messageUuid: 'msg-1', sourceOrder: 1 } }), + makeRecord('r2', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-1', + sourceOrder: 2, + }, + }), + ]; + + const summaries = selector.selectSummaries({ + records, + fileVersionsByPath: new Map([ + ['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }], + ]), + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.id).toBe('tool:/tmp/task.jsonl:tool-1'); + expect(summaries[0]?.source.toolUseId).toBe('tool-1'); + expect(summaries[0]?.anchor.kind).toBe('tool'); + expect(summaries[0]?.actionLabel).toBe('Added a comment'); + expect(summaries[0]?.actionCategory).toBe('comment'); + expect(summaries[0]?.canonicalToolName).toBe('task_add_comment'); + expect(summaries[0]?.records).toHaveLength(2); + expect(summaries[0]?.canLoadDetail).toBe(true); + }); + + it('marks summaries as non-expandable when file version metadata is missing', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const summaries = selector.selectSummaries({ + records: [makeRecord('r1')], + fileVersionsByPath: new Map(), + }); + + expect(summaries).toHaveLength(1); + expect(summaries[0]?.canLoadDetail).toBe(false); + }); + + it('builds distinct action labels for multiple tool-linked bundles from the same actor', () => { + const selector = new BoardTaskExactLogSummarySelector(); + const records = [ + makeRecord('r1', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-1', + toolUseId: 'tool-comment', + sourceOrder: 1, + }, + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'tool-comment', + category: 'comment', + }, + }), + makeRecord('r2', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-2', + toolUseId: 'tool-review', + sourceOrder: 2, + }, + action: { + canonicalToolName: 'review_request', + toolUseId: 'tool-review', + category: 'review', + details: { reviewer: 'tom' }, + }, + }), + makeRecord('r3', { + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'msg-3', + toolUseId: 'tool-read', + sourceOrder: 3, + }, + action: { + canonicalToolName: 'task_get', + toolUseId: 'tool-read', + category: 'read', + }, + }), + ]; + + const summaries = selector.selectSummaries({ + records, + fileVersionsByPath: new Map([ + ['/tmp/task.jsonl', { filePath: '/tmp/task.jsonl', mtimeMs: 1000, size: 42 }], + ]), + }); + + expect(summaries).toHaveLength(3); + expect(summaries.map((summary) => summary.actionLabel)).toEqual([ + 'Added a comment', + 'Requested review from tom', + 'Viewed task', + ]); + }); +}); diff --git a/test/main/services/team/BoardTaskExactLogsService.test.ts b/test/main/services/team/BoardTaskExactLogsService.test.ts new file mode 100644 index 00000000..fabaab78 --- /dev/null +++ b/test/main/services/team/BoardTaskExactLogsService.test.ts @@ -0,0 +1,82 @@ +import * as fs from 'fs/promises'; +import * as os from 'os'; +import * as path from 'path'; + +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskExactLogsService } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogsService'; + +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; + +const tempDirs: string[] = []; + +async function createTempTranscript(): Promise { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-summary-')); + tempDirs.push(dir); + const filePath = path.join(dir, 'transcript.jsonl'); + await fs.writeFile(filePath, '{"uuid":"x","type":"user","timestamp":"2026-04-12T16:00:00.000Z","message":{"role":"user","content":"hi"}}\n', 'utf8'); + return filePath; +} + +function makeRecord(filePath: string, id: string, timestamp: string, sourceOrder: number): BoardTaskActivityRecord { + return { + id, + timestamp, + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + source: { + filePath, + messageUuid: id, + sourceOrder, + }, + }; +} + +afterEach(async () => { + await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { recursive: true, force: true }))); + vi.unstubAllEnvs(); +}); + +describe('BoardTaskExactLogsService', () => { + it('returns empty when the exact-log read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { + getTaskRecords: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + + const service = new BoardTaskExactLogsService(recordSource as never); + await expect(service.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({ items: [] }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + }); + + it('returns summaries in deterministic source order for the renderer to present', async () => { + const filePath = await createTempTranscript(); + const recordSource = { + getTaskRecords: vi.fn(async () => [ + makeRecord(filePath, 'msg-older', '2026-04-12T16:00:00.000Z', 1), + makeRecord(filePath, 'msg-newer', '2026-04-12T16:05:00.000Z', 2), + ]), + }; + + const service = new BoardTaskExactLogsService(recordSource as never); + const response = await service.getTaskExactLogSummaries('demo', 'task-a'); + + expect(response.items).toHaveLength(2); + expect(response.items[0]?.timestamp).toBe('2026-04-12T16:00:00.000Z'); + expect(response.items[1]?.timestamp).toBe('2026-04-12T16:05:00.000Z'); + }); +}); diff --git a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts new file mode 100644 index 00000000..9232dd4a --- /dev/null +++ b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts @@ -0,0 +1,311 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityRecordSource } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordSource'; +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; +import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; + +import type { TeamTask } from '../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + workIntervals: [ + { + startedAt: '2026-04-12T15:36:00.000Z', + completedAt: '2026-04-12T15:40:00.000Z', + }, + ], + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: Record; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +describe('BoardTaskLogDiagnosticsService', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('explains when worker tools exist in transcript but only board MCP actions are explicit', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-diagnostics-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createAssistantEntry({ + uuid: 'a-task-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-task-start', + timestamp: '2026-04-12T15:36:00.100Z', + sourceToolAssistantUUID: 'a-task-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-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T15:36:30.000Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + text: 'Audit complete', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T15:36:30.100Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: '{"comment":{"text":"Audit complete"}}', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-comment', + content: '{"comment":{"text":"Audit complete"}}', + }, + }), + ]; + + await writeFile( + transcriptPath, + lines.map((line) => JSON.stringify(line)).join('\n'), + 'utf8', + ); + + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + listTranscriptFiles: async () => [transcriptPath], + }; + const recordSource = new BoardTaskActivityRecordSource( + transcriptSourceLocator as never, + taskReader as never, + new BoardTaskActivityTranscriptReader(), + new BoardTaskActivityRecordBuilder(), + ); + const streamService = new BoardTaskLogStreamService(recordSource); + const diagnosticsService = new BoardTaskLogDiagnosticsService( + taskReader as never, + transcriptSourceLocator as never, + recordSource, + undefined, + streamService, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, '#c414cd52'); + + expect(report.explicitRecords.execution).toBe(0); + expect(report.intervalToolResults.worker.total).toBe(1); + expect(report.intervalToolResults.worker.explicitLinked).toBe(0); + expect(report.intervalToolResults.worker.missingExplicit).toBe(1); + expect(report.intervalToolResults.worker.examples).toContainEqual( + expect.objectContaining({ + toolName: 'Grep', + toolUseId: 'call-grep', + }), + ); + expect(report.stream.visibleToolNames).toEqual([ + 'mcp__agent-teams__task_start', + 'mcp__agent-teams__task_add_comment', + ]); + expect(report.diagnosis.join(' ')).toContain('Only board MCP actions are explicit'); + }); +}); diff --git a/test/main/services/team/BoardTaskLogStream.live.test.ts b/test/main/services/team/BoardTaskLogStream.live.test.ts new file mode 100644 index 00000000..3f412717 --- /dev/null +++ b/test/main/services/team/BoardTaskLogStream.live.test.ts @@ -0,0 +1,72 @@ +import * as os from 'os'; +import * as path from 'path'; + +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskLogDiagnosticsService } from '../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder'; + +const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim(); +const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim(); +const LIVE_CLAUDE_BASE = + process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude'); +const EXPECT_MISSING_WORKER_LINKS = + process.env.LIVE_TASK_LOG_EXPECT_MISSING_WORKER_LINKS === '1'; +const EXPECT_NO_EMPTY_PAYLOADS = + process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1'; +const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const describeLive = + LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip; + +describeLive('BoardTaskLogStream live smoke', () => { + beforeAll(() => { + setClaudeBasePathOverride(LIVE_CLAUDE_BASE); + }); + + afterAll(() => { + setClaudeBasePathOverride(null); + }); + + it('diagnoses the current live task-log state', async () => { + const service = new BoardTaskLogDiagnosticsService(); + const streamService = new BoardTaskLogStreamService(); + let report; + try { + report = await service.diagnose(LIVE_TEAM!, LIVE_TASK!); + } catch (error) { + const fallbackTaskRef = + LIVE_TASK!.length > 8 && LIVE_TASK!.includes('-') ? LIVE_TASK!.slice(0, 8) : null; + if (!fallbackTaskRef) { + throw error; + } + report = await service.diagnose(LIVE_TEAM!, fallbackTaskRef); + } + + expect(report.task.taskId).toBeTruthy(); + expect(report.transcript.fileCount).toBeGreaterThan(0); + expect(report.diagnosis.length).toBeGreaterThan(0); + expect(report.stream.segmentCount).toBeGreaterThan(0); + + const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId); + expect(stream.segments.length).toBeGreaterThan(0); + + if (EXPECT_MISSING_WORKER_LINKS) { + expect(report.intervalToolResults.worker.missingExplicit).toBeGreaterThan(0); + } + + if (EXPECT_NO_EMPTY_PAYLOADS) { + expect(report.stream.emptyPayloadExamples).toHaveLength(0); + } + + if (EXPECT_VISIBLE_TOOLS.length > 0) { + for (const toolName of EXPECT_VISIBLE_TOOLS) { + expect(report.stream.visibleToolNames).toContain(toolName); + } + } + }); +}); diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts new file mode 100644 index 00000000..cc05960e --- /dev/null +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -0,0 +1,380 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; + +import { afterEach, describe, expect, it } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskActivityRecordBuilder } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { TeamTask } from '../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: unknown; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +async function buildRecordsFromTranscript(filePath: string, task: TeamTask) { + const transcriptReader = new BoardTaskActivityTranscriptReader(); + const recordBuilder = new BoardTaskActivityRecordBuilder(); + const messages = await transcriptReader.readFiles([filePath]); + + return recordBuilder.buildForTask({ + teamName: TEAM_NAME, + targetTask: task, + tasks: [task], + messages, + }); +} + +function flattenRawMessages(response: Awaited>): ParsedMessage[] { + return response.segments.flatMap((segment) => + segment.chunks.flatMap((chunk) => chunk.rawMessages), + ); +} + +describe('BoardTaskLogStreamService integration', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('includes worker tool logs when transcript rows carry execution links with toolUseId', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-integration-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:07.747Z', + 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-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-grep', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-edit', + timestamp: '2026-04-12T15:36:40.000Z', + requestId: 'req-edit', + content: [ + { + type: 'tool_use', + id: 'call-edit', + name: 'Edit', + input: { + file_path: 'docs-site/guide.md', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-edit', + timestamp: '2026-04-12T15:36:40.200Z', + sourceToolAssistantUUID: 'a-edit', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-edit', + content: 'File updated', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-edit', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-edit', + content: 'File updated', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + + const service = new BoardTaskLogStreamService(recordSource 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(response.segments).toHaveLength(1); + expect(toolNames).toContain('Grep'); + expect(toolNames).toContain('Edit'); + }); + + it('does not leak empty array board-tool payloads into the task log stream', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-tool-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask(); + + const lines = [ + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T18:35:02.000Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: TASK_ID, + text: 'Done', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T18:35:02.064Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}', + }, + ], + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "task": {\n "id": "c414cd52-470a-4b51-ae1e-e5250fff95d7"\n }\n}', + }, + ], + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const commentResult = rawMessages.find((message) => message.uuid === 'u-comment'); + + expect(response.segments).toHaveLength(1); + expect(commentResult).toBeUndefined(); + }); +}); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts new file mode 100644 index 00000000..41410e0d --- /dev/null +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -0,0 +1,639 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; + +import type { ParsedMessage } from '../../../../src/main/types'; +import type { BoardTaskActivityRecord } from '../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { BoardTaskExactLogBundleCandidate } from '../../../../src/main/services/team/taskLogs/exact/BoardTaskExactLogTypes'; + +function makeRecord( + id: string, + timestamp: string, + actor: BoardTaskActivityRecord['actor'], + toolUseId?: string, +): BoardTaskActivityRecord { + return { + id, + timestamp, + task: { + locator: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + resolution: 'resolved', + }, + linkKind: 'board_action', + targetRole: 'subject', + actor, + actorContext: { relation: 'same_task' }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + ...(toolUseId ? { toolUseId } : {}), + sourceOrder: 1, + }, + }; +} + +function makeCandidate( + id: string, + timestamp: string, + actor: BoardTaskActivityRecord['actor'], + toolUseId?: string, +): BoardTaskExactLogBundleCandidate { + const record = makeRecord(id, timestamp, actor, toolUseId); + return { + id, + timestamp, + actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + ...(toolUseId ? { toolUseId } : {}), + sourceOrder: 1, + }, + records: [record], + anchor: toolUseId + ? { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + toolUseId, + } + : { + kind: 'message', + filePath: '/tmp/task.jsonl', + messageUuid: `${id}-msg`, + }, + actionLabel: 'Worked on task', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }; +} + +function makeMessage(uuid: string, timestamp: string, text: string): ParsedMessage { + return { + uuid, + parentUuid: null, + type: 'assistant', + timestamp: new Date(timestamp), + role: 'assistant', + content: [{ type: 'text', text } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }; +} + +describe('BoardTaskLogStreamService', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('returns empty when the stream read flag is disabled', async () => { + vi.stubEnv('CLAUDE_TEAM_BOARD_TASK_EXACT_LOGS_READ_ENABLED', 'false'); + const recordSource = { + getTaskRecords: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + expect(recordSource.getTaskRecords).not.toHaveBeenCalled(); + }); + + it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const alice = { + memberName: 'alice', + role: 'member' as const, + sessionId: 'session-alice', + agentId: 'agent-alice', + isSidechain: true, + }; + const lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), + makeCandidate('c3', '2026-04-12T16:02:00.000Z', alice, 'tool-3'), + makeCandidate('c4', '2026-04-12T16:03:00.000Z', lead), + makeCandidate('c5', '2026-04-12T16:04:00.000Z', tom, 'tool-4'), + ]; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), + }; + const summarySelector = { + selectSummaries: vi.fn(() => candidates), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], + })), + }; + 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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.defaultFilter).toBe('all'); + expect(response.participants.map((participant) => participant.key)).toEqual([ + 'member:tom', + 'member:alice', + ]); + expect(response.segments.map((segment) => segment.participantKey)).toEqual([ + 'member:tom', + 'member:alice', + 'member:tom', + ]); + expect(buildBundleChunks).toHaveBeenCalledTimes(3); + expect(buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); + }); + + it('merges duplicate message uuids inside one participant segment before chunk building', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:00:10.000Z', tom, 'tool-2'), + ]; + + const sharedMessage = { + uuid: 'assistant-shared', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), + }; + const summarySelector = { + selectSummaries: vi.fn(() => candidates), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi + .fn() + .mockImplementationOnce(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 1 }, + records: candidates[0]!.records, + filteredMessages: [ + { + ...sharedMessage, + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + }, + ], + })) + .mockImplementationOnce(() => ({ + id: 'c2', + timestamp: '2026-04-12T16:00:10.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-shared', sourceOrder: 2 }, + records: candidates[1]!.records, + filteredMessages: [ + { + ...sharedMessage, + content: [{ type: 'text', text: 'task looked up' } as never], + }, + ], + })), + }; + 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'); + + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages).toHaveLength(1); + expect(mergedMessages[0]?.toolCalls).toHaveLength(1); + expect(Array.isArray(mergedMessages[0]?.content)).toBe(true); + expect(mergedMessages[0]?.content).toHaveLength(2); + }); + + it('drops tool-anchored assistant output-only messages to avoid noisy raw result blocks', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-output', + parentUuid: 'assistant-tool', + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'assistant', + content: [{ type: 'text', text: '[{\"type\":\"text\",\"text\":\"{\\n \\\"id\\\": \\\"task-a\\\"\\n}\"}]' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [{ type: 'tool_result', tool_use_id: 'tool-1', content: 'ok' } as never], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: 'ok' }, + isSidechain: true, + 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'); + + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages.map((message) => message.uuid)).toEqual(['assistant-tool', 'user-result']); + }); + + it('defaults to the single named participant and excludes unnamed lead noise when named task logs exist', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const unknownLead = { + role: 'unknown' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidates = [ + makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + makeCandidate('c2', '2026-04-12T16:01:00.000Z', unknownLead, 'tool-2'), + ]; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidates.flatMap((candidate) => candidate.records)), + }; + const summarySelector = { + selectSummaries: vi.fn(() => candidates), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], + })), + }; + 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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.participants.map((participant) => participant.key)).toEqual(['member:tom']); + expect(response.defaultFilter).toBe('member:tom'); + expect(response.segments.map((segment) => segment.participantKey)).toEqual(['member:tom']); + }); + + it('sanitizes json-like tool_result payload text while preserving the tool result message', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + agentId: 'agent-tom', + isSidechain: true, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'); + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_get', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: [{ type: 'text', text: '{\n \"id\": \"task-a\"\n}' } as never], + } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: '{\n \"id\": \"task-a\"\n}' }, + isSidechain: true, + 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 toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); + expect(toolResultMessage).toBeDefined(); + const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; + expect(content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: '', + }); + expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: '' }); + }); + + it('drops read-only slices when the same participant has more meaningful task logs', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const readCandidate = { ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), actionCategory: 'read' as const, canonicalToolName: 'task_get' }; + const commentCandidate = { ...makeCandidate('c2', '2026-04-12T16:01:00.000Z', tom, 'tool-2'), actionCategory: 'comment' as const, canonicalToolName: 'task_add_comment' }; + + const recordSource = { + getTaskRecords: vi.fn(async () => [...readCandidate.records, ...commentCandidate.records]), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [readCandidate, commentCandidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => ({ + id: candidate.id, + timestamp: candidate.timestamp, + actor: candidate.actor, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage(candidate.id, candidate.timestamp, candidate.id)], + })), + }; + 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, + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.segments).toHaveLength(1); + expect(buildBundleChunks).toHaveBeenCalledTimes(1); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']); + }); + + it('extracts task_add_comment text from json-like tool result payload', async () => { + const tom = { + memberName: 'tom', + role: 'lead' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const candidate = { + ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', tom, 'tool-1'), + actionCategory: 'comment' as const, + canonicalToolName: 'task_add_comment', + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: tom, + source: { filePath: '/tmp/task.jsonl', messageUuid: 'assistant-tool', toolUseId: 'tool-1', sourceOrder: 1 }, + records: candidate.records, + filteredMessages: [ + { + uuid: 'assistant-tool', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [{ type: 'tool_use', id: 'tool-1', name: 'task_add_comment', input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-result', + parentUuid: 'assistant-tool', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:00:02.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: [{ type: 'text', text: '{\"comment\":{\"text\":\"useful comment\"}}' } as never], + } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'tool-1', + sourceToolAssistantUUID: 'assistant-tool', + toolUseResult: { toolUseId: 'tool-1', content: '{"comment":{"text":"useful comment"}}' }, + 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 toolResultMessage = mergedMessages.find((message) => message.uuid === 'user-result'); + const content = Array.isArray(toolResultMessage?.content) ? toolResultMessage.content : []; + expect(content[0]).toMatchObject({ + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'useful comment', + }); + expect(toolResultMessage?.toolUseResult).toEqual({ toolUseId: 'tool-1', content: 'useful comment' }); + }); +}); diff --git a/test/main/services/team/BoardTaskTranscriptContract.test.ts b/test/main/services/team/BoardTaskTranscriptContract.test.ts new file mode 100644 index 00000000..e30afd10 --- /dev/null +++ b/test/main/services/team/BoardTaskTranscriptContract.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from 'vitest'; +import fixture from '../../../fixtures/team/board-task-activity-message-v1.json'; + +import { + parseBoardTaskLinks, + parseBoardTaskToolActions, +} from '../../../../src/main/services/team/taskLogs/contract/BoardTaskTranscriptContract'; + +describe('BoardTaskTranscriptContract', () => { + it('salvages valid board-task links from mixed payloads', () => { + const parsed = parseBoardTaskLinks([ + null, + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + { + schemaVersion: 1, + task: { ref: '', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'lifecycle', + actorContext: { relation: 'idle' }, + }, + ]); + }); + + it('salvages valid task tool actions from mixed payloads', () => { + const parsed = parseBoardTaskToolActions([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + { + schemaVersion: 1, + canonicalToolName: 'task_add_comment', + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ]); + }); + + it('parses the documented fixture example', () => { + expect(parseBoardTaskLinks(fixture.boardTaskLinks)).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { + ref: 'abcd1234', + refKind: 'display', + canonicalId: '123e4567-e89b-12d3-a456-426614174000', + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { relation: 'idle' }, + }, + ]); + + expect(parseBoardTaskToolActions(fixture.boardTaskToolActions)).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + canonicalToolName: 'task_add_comment', + resultRefs: { commentId: 'comment-1' }, + }, + ]); + }); + + it('preserves semantic null owner and clarification values', () => { + const parsed = parseBoardTaskToolActions([ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_set_owner', + input: { owner: null }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_set_clarification', + input: { clarification: 'clear' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-2', + canonicalToolName: 'task_set_owner', + input: { owner: null }, + }, + { + schemaVersion: 1, + toolUseId: 'tool-3', + canonicalToolName: 'task_set_clarification', + input: { clarification: null }, + }, + ]); + }); + + it('accepts legacy version fields while preferring schemaVersion going forward', () => { + const parsed = parseBoardTaskLinks([ + { + version: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); + + it('sanitizes impossible actor scope details unless relation is other_active_task', () => { + const parsed = parseBoardTaskLinks([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + activeTask: { ref: 'efgh5678', refKind: 'display' }, + activePhase: 'work', + activeExecutionSeq: 2, + }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); + + it('preserves execution toolUseId while still dropping execution taskArgumentSlot', () => { + const parsed = parseBoardTaskLinks([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + taskArgumentSlot: 'taskId', + actorContext: { relation: 'same_task' }, + }, + ]); + + expect(parsed).toEqual([ + { + schemaVersion: 1, + toolUseId: 'tool-1', + task: { ref: 'abcd1234', refKind: 'display' }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { relation: 'same_task' }, + }, + ]); + }); +}); diff --git a/test/main/services/team/TaskBoundaryParser.test.ts b/test/main/services/team/TaskBoundaryParser.test.ts index e1f592bb..e60b7cb1 100644 --- a/test/main/services/team/TaskBoundaryParser.test.ts +++ b/test/main/services/team/TaskBoundaryParser.test.ts @@ -64,6 +64,53 @@ describe('TaskBoundaryParser', () => { expect(result.boundaries.every((entry) => entry.mechanism === 'mcp')).toBe(true); }); + it('detects fully-qualified agent-teams MCP task boundaries', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); + const jsonlPath = path.join(tmpDir, 'mcp-qualified.jsonl'); + await fs.writeFile( + jsonlPath, + [ + JSON.stringify({ + timestamp: '2026-03-01T10:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'mcp__agent-teams__task_start', + input: { taskId: 'task-123', teamName: 'demo' }, + }, + ], + }, + }), + JSON.stringify({ + timestamp: '2026-03-01T10:10:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-2', + name: 'mcp__agent_teams__task_complete', + input: { taskId: 'task-123', teamName: 'demo' }, + }, + ], + }, + }), + ].join('\n') + '\n', + 'utf8' + ); + + const result = await new TaskBoundaryParser().parseBoundaries(jsonlPath); + + expect(result.detectedMechanism).toBe('mcp'); + expect(result.boundaries).toHaveLength(2); + expect(result.boundaries.map((entry) => entry.event)).toEqual(['start', 'complete']); + }); + it('ignores legacy teamctl bash markers and keeps modern MCP markers only', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'task-boundary-parser-')); const jsonlPath = path.join(tmpDir, 'mixed.jsonl'); diff --git a/test/main/services/team/TeamMemberLogsFinder.test.ts b/test/main/services/team/TeamMemberLogsFinder.test.ts index 382b4198..9c7d559e 100644 --- a/test/main/services/team/TeamMemberLogsFinder.test.ts +++ b/test/main/services/team/TeamMemberLogsFinder.test.ts @@ -867,6 +867,34 @@ describe('TeamMemberLogsFinder', () => { await expect(finder.hasTaskUpdateMarker(noisePath, 'task-42')).resolves.toBe(false); }); + it('detects fully-qualified agent-teams task markers in JSONL', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-markers-')); + const qualifiedPath = path.join(tmpDir, 'qualified.jsonl'); + + await fs.writeFile( + qualifiedPath, + JSON.stringify({ + timestamp: '2026-01-01T00:00:00.000Z', + type: 'assistant', + message: { + role: 'assistant', + content: [ + { + type: 'tool_use', + name: 'mcp__agent-teams__task_start', + input: { teamName: 'demo', taskId: 'task-42' }, + }, + ], + }, + }) + '\n', + 'utf8' + ); + + const finder = new TeamMemberLogsFinder(); + + await expect(finder.hasTaskUpdateMarker(qualifiedPath, 'task-42')).resolves.toBe(true); + }); + it('findLogFileRefsForTask returns correct refs for a task', async () => { tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'claude-team-refs-')); setClaudeBasePathOverride(tmpDir); diff --git a/test/renderer/api/httpClient.exactTaskLogs.test.ts b/test/renderer/api/httpClient.exactTaskLogs.test.ts new file mode 100644 index 00000000..062aa1f5 --- /dev/null +++ b/test/renderer/api/httpClient.exactTaskLogs.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { HttpAPIClient } from '../../../src/renderer/api/httpClient'; + +class MockEventSource { + onopen: (() => void) | null = null; + onerror: (() => void) | null = null; + addEventListener(): void {} + close(): void {} +} + +describe('HttpAPIClient exact task logs browser fallback', () => { + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + it('returns safe fallback shapes for exact task logs in browser mode', async () => { + vi.stubGlobal('EventSource', MockEventSource); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const client = new HttpAPIClient('http://localhost:9999'); + + await expect(client.teams.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + await expect(client.teams.getTaskExactLogSummaries('demo', 'task-a')).resolves.toEqual({ + items: [], + }); + await expect( + client.teams.getTaskExactLogDetail('demo', 'task-a', 'bundle-1', 'gen-1') + ).resolves.toEqual({ status: 'missing' }); + + expect(warnSpy).toHaveBeenCalled(); + }); +}); diff --git a/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts b/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts new file mode 100644 index 00000000..d55aba99 --- /dev/null +++ b/test/renderer/components/team/taskLogs/ExactTaskLogsSection.test.ts @@ -0,0 +1,288 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { + BoardTaskExactLogDetailResult, + BoardTaskExactLogSummariesResponse, +} from '../../../../../src/shared/types'; + +const apiState = { + getTaskExactLogSummaries: vi.fn< + (teamName: string, taskId: string) => Promise + >(), + getTaskExactLogDetail: vi.fn< + ( + teamName: string, + taskId: string, + exactLogId: string, + expectedSourceGeneration: string + ) => Promise + >(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskExactLogSummaries: (...args: Parameters) => + apiState.getTaskExactLogSummaries(...args), + getTaskExactLogDetail: (...args: Parameters) => + apiState.getTaskExactLogDetail(...args), + }, + }, +})); + +vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ + MemberExecutionLog: ({ memberName }: { memberName?: string }) => + React.createElement('div', { 'data-testid': 'member-execution-log' }, memberName ?? 'no-name'), +})); + +import { ExactTaskLogsSection } from '@renderer/components/team/taskLogs/ExactTaskLogsSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('ExactTaskLogsSection', () => { + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskExactLogSummaries.mockReset(); + apiState.getTaskExactLogDetail.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders empty state when exact summaries are absent', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockResolvedValueOnce({ items: [] }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Exact Task Logs'); + expect(host.textContent).toContain('No exact task logs yet'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders loading state while summaries are still pending', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + let resolveSummaries: ((value: BoardTaskExactLogSummariesResponse) => void) | null = null; + apiState.getTaskExactLogSummaries.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveSummaries = resolve; + }) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Loading exact task logs'); + + await act(async () => { + resolveSummaries?.({ items: [] }); + await flushMicrotasks(); + }); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders error state when summaries fail to load', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockRejectedValueOnce(new Error('boom')); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('boom'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('reloads summaries on stale detail and then renders exact detail', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries + .mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-1', + }, + ], + }) + .mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-1', + agentId: 'agent-1', + isSidechain: true, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + canLoadDetail: true, + sourceGeneration: 'gen-2', + }, + ], + }); + apiState.getTaskExactLogDetail + .mockResolvedValueOnce({ status: 'stale' }) + .mockResolvedValueOnce({ + status: 'ok', + detail: { + id: 'tool:/tmp/task.jsonl:tool-1', + chunks: [], + }, + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + const button = host.querySelector('button'); + expect(button).not.toBeNull(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + await vi.waitFor(() => { + expect(apiState.getTaskExactLogSummaries).toHaveBeenCalledTimes(2); + expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith( + 1, + 'demo', + 'task-a', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-1' + ); + expect(apiState.getTaskExactLogDetail).toHaveBeenNthCalledWith( + 2, + 'demo', + 'task-a', + 'tool:/tmp/task.jsonl:tool-1', + 'gen-2' + ); + expect(host.querySelector('[data-testid=\"member-execution-log\"]')?.textContent).toBe('alice'); + }); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('renders descriptive action labels and lead-session fallback actor text', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskExactLogSummaries.mockResolvedValueOnce({ + items: [ + { + id: 'tool:/tmp/task.jsonl:tool-1', + timestamp: '2026-04-12T18:00:00.000Z', + actor: { + role: 'lead', + sessionId: 'lead-session-1', + isSidechain: false, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'assistant-1', + toolUseId: 'tool-1', + sourceOrder: 1, + }, + anchorKind: 'tool', + actionLabel: 'Requested review', + actionCategory: 'review', + canonicalToolName: 'review_request', + linkKinds: ['board_action'], + canLoadDetail: false, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(ExactTaskLogsSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('lead session'); + expect(host.textContent).toContain('Requested review'); + expect(host.textContent).toContain('tool'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts new file mode 100644 index 00000000..00ad81c9 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -0,0 +1,550 @@ +import { mkdtemp, rm, writeFile } from 'fs/promises'; +import { tmpdir } from 'os'; +import path from 'path'; +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { BoardTaskActivityRecordBuilder } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder'; +import { BoardTaskActivityTranscriptReader } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; + +import type { TeamTask } from '../../../../../src/shared/types'; + +const TEAM_NAME = 'beacon-desk-2'; +const TASK_ID = 'c414cd52-470a-4b51-ae1e-e5250fff95d7'; + +const apiState = { + getTaskLogStream: vi.fn(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function createTask(overrides: Partial = {}): TeamTask { + return { + id: TASK_ID, + displayId: 'c414cd52', + subject: 'Help alice: fast lint/link check', + status: 'completed', + ...overrides, + }; +} + +function createAssistantEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + agentName?: string; + sessionId?: string; + requestId?: string; +}): Record { + return { + type: 'assistant', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + requestId: args.requestId, + message: { + id: `${args.uuid}-msg`, + role: 'assistant', + model: 'claude-test', + type: 'message', + stop_reason: 'tool_use', + stop_sequence: null, + usage: { + input_tokens: 0, + output_tokens: 0, + }, + content: args.content, + }, + }; +} + +function createUserEntry(args: { + uuid: string; + timestamp: string; + content: unknown[]; + boardTaskLinks?: unknown[]; + boardTaskToolActions?: unknown[]; + toolUseResult?: unknown; + sourceToolAssistantUUID?: string; + agentName?: string; + sessionId?: string; +}): Record { + return { + type: 'user', + uuid: args.uuid, + timestamp: args.timestamp, + sessionId: args.sessionId ?? 'session-tom', + teamName: TEAM_NAME, + agentName: args.agentName ?? 'tom', + isSidechain: false, + ...(args.boardTaskLinks ? { boardTaskLinks: args.boardTaskLinks } : {}), + ...(args.boardTaskToolActions ? { boardTaskToolActions: args.boardTaskToolActions } : {}), + ...(args.toolUseResult ? { toolUseResult: args.toolUseResult } : {}), + ...(args.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: args.sourceToolAssistantUUID } + : {}), + message: { + role: 'user', + content: args.content, + }, + }; +} + +async function buildStreamResponse(transcriptPath: string) { + const task = createTask(); + const transcriptReader = new BoardTaskActivityTranscriptReader(); + const recordBuilder = new BoardTaskActivityRecordBuilder(); + const messages = await transcriptReader.readFiles([transcriptPath]); + const recordSource = { + getTaskRecords: async () => + recordBuilder.buildForTask({ + teamName: TEAM_NAME, + targetTask: task, + tasks: [task], + messages, + }), + }; + + const service = new BoardTaskLogStreamService(recordSource as never); + return service.getTaskLogStream(TEAM_NAME, task.id); +} + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('TaskLogStreamSection integration', () => { + const tempDirs: string[] = []; + + afterEach(async () => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + await Promise.all( + tempDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })), + ); + }); + + it('renders worker tools and does not show empty array output blocks', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-render-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + + const lines = [ + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T15:36:07.747Z', + 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-grep', + timestamp: '2026-04-12T15:36:14.522Z', + requestId: 'req-grep', + content: [ + { + type: 'tool_use', + id: 'call-grep', + name: 'Grep', + input: { + pattern: 'ITERATION_PLAN', + path: 'docs-site', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-grep', + timestamp: '2026-04-12T15:36:14.749Z', + sourceToolAssistantUUID: 'a-grep', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-grep', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-grep', + content: 'docs-site/guide.md:42: ITERATION_PLAN', + }, + }), + createAssistantEntry({ + uuid: 'a-edit', + timestamp: '2026-04-12T15:36:40.000Z', + requestId: 'req-edit', + content: [ + { + type: 'tool_use', + id: 'call-edit', + name: 'Edit', + input: { + file_path: 'docs-site/guide.md', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-edit', + timestamp: '2026-04-12T15:36:40.200Z', + sourceToolAssistantUUID: 'a-edit', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-edit', + content: 'File updated', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-edit', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'execution', + actorContext: { + relation: 'same_task', + }, + }, + ], + toolUseResult: { + toolUseId: 'call-edit', + content: 'File updated', + }, + }), + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-12T15:47:44.500Z', + requestId: 'req-comment', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'mcp__agent-teams__task_add_comment', + input: { + taskId: TASK_ID, + text: 'Audit complete', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-12T15:47:44.773Z', + sourceToolAssistantUUID: 'a-comment', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}', + }, + ], + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'same_task', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + resultRefs: { + commentId: 'comment-1', + }, + }, + ], + toolUseResult: [ + { + type: 'text', + text: '{\n "commentId": "comment-1",\n "comment": {\n "text": "Audit complete"\n }\n}', + }, + ], + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('Grep'); + expect(text).toContain('Edit'); + expect(text).toContain('Claude'); + expect(text).toContain('2 tool calls'); + expect(text).toContain('Audit complete'); + expect(text).not.toContain('[]'); + expect(text).not.toContain('lead session'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('does not render empty board lifecycle payload blocks for task_start/task_complete', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-board-lifecycle-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + + const lines = [ + createAssistantEntry({ + uuid: 'a-start', + timestamp: '2026-04-12T18:25:04.000Z', + requestId: 'req-start', + content: [ + { + type: 'tool_use', + id: 'call-start', + name: 'mcp__agent-teams__task_start', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-start', + timestamp: '2026-04-12T18:25:04.039Z', + sourceToolAssistantUUID: 'a-start', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-start', + content: '', + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-start', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'lifecycle', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'idle', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-start', + canonicalToolName: 'task_start', + }, + ], + toolUseResult: { + toolUseId: 'call-start', + content: '', + }, + }), + createAssistantEntry({ + uuid: 'a-complete', + timestamp: '2026-04-12T18:27:04.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-12T18:27:04.039Z', + sourceToolAssistantUUID: 'a-complete', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-complete', + content: '', + }, + ], + 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: '', + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + apiState.getTaskLogStream.mockResolvedValueOnce(await buildStreamResponse(transcriptPath)); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { teamName: TEAM_NAME, taskId: TASK_ID }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('mcp__agent-teams__task_start'); + expect(text).toContain('mcp__agent-teams__task_complete'); + expect(text).not.toContain('[]'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts new file mode 100644 index 00000000..1ba108f6 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.live.test.ts @@ -0,0 +1,107 @@ +import * as os from 'os'; +import * as path from 'path'; + +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from 'vitest'; + +import { BoardTaskLogDiagnosticsService } from '../../../../../src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService'; +import { BoardTaskLogStreamService } from '../../../../../src/main/services/team/taskLogs/stream/BoardTaskLogStreamService'; +import { TooltipProvider } from '../../../../../src/renderer/components/ui/tooltip'; +import { setClaudeBasePathOverride } from '../../../../../src/main/utils/pathDecoder'; + +const LIVE_TEAM = process.env.LIVE_TASK_LOG_TEAM?.trim(); +const LIVE_TASK = process.env.LIVE_TASK_LOG_TASK?.trim(); +const LIVE_CLAUDE_BASE = + process.env.LIVE_TASK_LOG_CLAUDE_BASE?.trim() || path.join(os.homedir(), '.claude'); +const EXPECT_NO_EMPTY_PAYLOADS = + process.env.LIVE_TASK_LOG_EXPECT_NO_EMPTY_PAYLOADS === '1'; +const EXPECT_VISIBLE_TOOLS = (process.env.LIVE_TASK_LOG_EXPECT_VISIBLE_TOOLS ?? '') + .split(',') + .map((value) => value.trim()) + .filter(Boolean); + +const describeLive = + LIVE_TEAM && LIVE_TASK && LIVE_CLAUDE_BASE ? describe : describe.skip; + +const apiState = { + getTaskLogStream: vi.fn(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describeLive('TaskLogStreamSection live smoke', () => { + beforeAll(() => { + setClaudeBasePathOverride(LIVE_CLAUDE_BASE); + }); + + afterAll(() => { + setClaudeBasePathOverride(null); + }); + + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders the current live task log stream without empty payload placeholders', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const diagnosticsService = new BoardTaskLogDiagnosticsService(); + const streamService = new BoardTaskLogStreamService(); + const report = await diagnosticsService.diagnose(LIVE_TEAM!, LIVE_TASK!); + const stream = await streamService.getTaskLogStream(LIVE_TEAM!, report.task.taskId); + + apiState.getTaskLogStream.mockResolvedValueOnce(stream); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { + teamName: LIVE_TEAM!, + taskId: report.task.taskId, + }), + ), + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).not.toContain('Loading task log stream'); + expect(host.textContent).not.toContain('[]'); + + if (EXPECT_NO_EMPTY_PAYLOADS) { + expect(report.stream.emptyPayloadExamples).toHaveLength(0); + } + + for (const toolName of EXPECT_VISIBLE_TOOLS) { + expect(host.textContent).toContain(toolName); + } + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts new file mode 100644 index 00000000..4f34bdc0 --- /dev/null +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.test.ts @@ -0,0 +1,223 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { BoardTaskLogStreamResponse } from '../../../../../src/shared/types'; + +const apiState = { + getTaskLogStream: vi.fn< + (teamName: string, taskId: string) => Promise + >(), +}; + +vi.mock('@renderer/api', () => ({ + api: { + teams: { + getTaskLogStream: (...args: Parameters) => + apiState.getTaskLogStream(...args), + }, + }, +})); + +vi.mock('@renderer/components/team/members/MemberExecutionLog', () => ({ + MemberExecutionLog: ({ + memberName, + chunks, + }: { + memberName?: string; + chunks: { id: string }[]; + }) => + React.createElement( + 'div', + { 'data-testid': 'member-execution-log' }, + `${memberName ?? 'lead'}:${chunks.length}` + ), +})); + +import { TaskLogStreamSection } from '@renderer/components/team/taskLogs/TaskLogStreamSection'; + +function flushMicrotasks(): Promise { + return Promise.resolve(); +} + +describe('TaskLogStreamSection', () => { + afterEach(() => { + document.body.innerHTML = ''; + apiState.getTaskLogStream.mockReset(); + vi.unstubAllGlobals(); + }); + + it('renders empty state when the stream is absent', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('Task Log Stream'); + expect(host.textContent).toContain('No task log stream yet'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('shows participant chips and filters the visible segments', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:tom', + label: 'tom', + role: 'member', + isLead: false, + isSidechain: true, + }, + { + key: 'member:alice', + label: 'alice', + role: 'member', + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'all', + segments: [ + { + id: 'segment-tom-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member', + sessionId: 'session-tom-1', + agentId: 'agent-tom', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + chunks: [{ id: 'chunk-tom-1', chunkType: 'user', rawMessages: [] }] as never, + }, + { + id: 'segment-alice-1', + participantKey: 'member:alice', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-alice-1', + agentId: 'agent-alice', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:02:00.000Z', + endTimestamp: '2026-04-12T16:03:00.000Z', + chunks: [{ id: 'chunk-alice-1', chunkType: 'user', rawMessages: [] }] as never, + }, + { + id: 'segment-tom-2', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member', + sessionId: 'session-tom-2', + agentId: 'agent-tom', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:04:00.000Z', + endTimestamp: '2026-04-12T16:05:00.000Z', + chunks: [{ id: 'chunk-tom-2', chunkType: 'user', rawMessages: [] }] as never, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('All'); + expect(host.textContent).toContain('tom'); + expect(host.textContent).toContain('alice'); + expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(3); + + const buttons = [...host.querySelectorAll('button')]; + const tomButton = buttons.find((button) => button.textContent?.trim() === 'tom'); + expect(tomButton).toBeDefined(); + + await act(async () => { + tomButton?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await flushMicrotasks(); + }); + + const logs = [...host.querySelectorAll('[data-testid="member-execution-log"]')].map( + (node) => node.textContent + ); + expect(logs).toEqual(['tom:1', 'tom:1']); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); + + it('honors a participant default filter from the stream response', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.getTaskLogStream.mockResolvedValueOnce({ + participants: [ + { + key: 'member:tom', + label: 'tom', + role: 'member', + isLead: false, + isSidechain: false, + }, + ], + defaultFilter: 'member:tom', + segments: [ + { + id: 'segment-tom-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'lead', + sessionId: 'session-tom-1', + isSidechain: false, + }, + startTimestamp: '2026-04-12T16:00:00.000Z', + endTimestamp: '2026-04-12T16:01:00.000Z', + chunks: [{ id: 'chunk-tom-1', chunkType: 'ai', rawMessages: [] }] as never, + }, + ], + }); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(TaskLogStreamSection, { teamName: 'demo', taskId: 'task-a' })); + await flushMicrotasks(); + }); + + expect(host.querySelectorAll('[data-testid="member-execution-log"]')).toHaveLength(1); + expect(host.textContent).toContain('tom:1'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); +});