From aa58409e4ddb69741ff4ac05c187e246a3cc21aa Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 1 Sep 2026 07:32:46 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(tui):=20consume=20odek=20wire=20v2=20?= =?UTF-8?q?=E2=80=94=20identity,=20queued,=20budgets,=20artifacts,=20cost?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the bodek side of TEMP_ODEK_SUBAGENTS_WIRE_TASKS.md (P1-P6), all fields omitempty so pre-v2 engines degrade to current behavior: - P1: started frames seed cards with goal/profile/effective max_risk, overriding the delegate-arg manifest; trust badges render in the expanded details - P2: queued tasks render as (circled) queued cards, count separately in the rollup, satisfy pending slots, and upgrade on started - P3: budget horizons render as 'it 9/15' and 'elapsed/30m' pairings, only when the engine declares caps - P4: framed results list artifact refs (id - uri, humanized size) - P5 resolved deny-not-prompt: no approval state, documented in README - P6: per-task cost renders as an estimate ('~sh.0421'), capped form included; denials render in the result card with a transient note Also: client.Event grows wire-v2 fields (approvals head-compare now uses the event ID — the slice field made structs non-comparable); registry entries carry the new fields through the agents tab detail. --- README.md | 11 +- internal/client/client.go | 38 +++++- internal/client/rest.go | 9 ++ internal/tui/approval_expiry.go | 2 +- internal/tui/events.go | 9 +- internal/tui/mgmt.go | 44 ++++++- internal/tui/subagent_wire_v2_test.go | 163 +++++++++++++++++++++++++ internal/tui/subagents.go | 168 ++++++++++++++++++++++---- internal/tui/view.go | 19 ++- 9 files changed, 432 insertions(+), 31 deletions(-) create mode 100644 internal/tui/subagent_wire_v2_test.go diff --git a/README.md b/README.md index 2cc9d5a..e53570e 100644 --- a/README.md +++ b/README.md @@ -201,7 +201,13 @@ own front-end settings are separate; see [Configuration](#configuration). `2/3 · 1 ✗ · 8.1k tok` — a terminal failure sticks to the notice strip until the turn ends, and every delegating turn closes with a `swarm: 5 ✓ · 1 ✗ — SA4 error` verdict. A disconnect retires in-flight - cards (`× lost on disconnect`) instead of leaving ghost spinners. + cards (`× lost on disconnect`) instead of leaving ghost spinners. Wire v2 + (odek): queued tasks render as `◌ · queued` and count in the rollup + (`0/8 agents · 6 queued`); cards carry trust badges (resolved profile + + effective risk ceiling, in the expanded details), budget horizons + (`it 9/15`, `12s/30m`), and per-task cost (`~$0.0421/$0.5` when priced); + result cards list artifacts (`⎘`) and policy denials (`⊘ N denied`) — + sub-agents are deny-not-prompt: they never block on approvals. `ctrl+s` (or `/stop `, two-step confirmed) stops one running sub-agent of the current turn; the `/agents` tab's `c` reaches any live task through the instance registry. @@ -377,7 +383,8 @@ full command and press `⏎`. `p` refresh pending approvals, `e` drill into the run's event trail. - **Agents** — the serve instance's sub-agent registry, live-polled every 3s; `c` stop the highlighted row (two-step, same gate as `/stop`), `o` jump to - the delegating transcript step, `⏎` the full registry record. + the delegating transcript step, `⏎` the full registry record — trust, + budget, cost, and artifact lines included. - **Events** — the `odek.event/v1` ring: `f` filter to this session, `x` clear filters (a runs-tab drill-in scopes it to one run). - **Plan** — the engine's structured task plan (Telegram-parity renderer): diff --git a/internal/client/client.go b/internal/client/client.go index 8587838..044d385 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -103,12 +103,48 @@ type Event struct { // (both frames carry "status"). TaskID string `json:"task_id,omitempty"` RunKey string `json:"run_key,omitempty"` - Phase string `json:"phase,omitempty"` // started | active | finished + Phase string `json:"phase,omitempty"` // queued | started | active | finished Step int `json:"step,omitempty"` Iterations int `json:"iterations,omitempty"` Tool string `json:"tool,omitempty"` DurationSeconds float64 `json:"duration_seconds,omitempty"` TokensUsed int `json:"tokens_used,omitempty"` + + // Wire v2 (odek): identity, budgets, and cost on state frames — all + // omitted when unset, so older engines degrade to the block above only. + Goal string `json:"goal,omitempty"` + Profile string `json:"profile,omitempty"` + MaxRisk string `json:"max_risk,omitempty"` + BudgetSeconds int `json:"budget_seconds,omitempty"` + BudgetIterations int `json:"budget_iterations,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + BudgetCostUSD float64 `json:"budget_cost_usd,omitempty"` + Artifacts []StateArtifact `json:"artifacts,omitempty"` +} + +// StateArtifact is the bounded artifact metadata carried on subagent_state +// frames and registry entries (wire v2). +type StateArtifact struct { + ID string `json:"id"` + Path string `json:"path,omitempty"` + Bytes int64 `json:"bytes,omitempty"` +} + +// ResultArtifact is one artifact.Ref from a framed sub-agent result +// (odek.artifact-ref/v1). +type ResultArtifact struct { + ID string `json:"id"` + URI string `json:"uri,omitempty"` + MediaType string `json:"media_type,omitempty"` + Summary string `json:"summary,omitempty"` + SizeBytes *int64 `json:"size_bytes,omitempty"` +} + +// ResultDenial is one policy denial reported in a framed sub-agent result. +type ResultDenial struct { + Tool string `json:"tool"` + Class string `json:"class,omitempty"` + Reason string `json:"reason"` } // EventDisconnected is a synthetic Type emitted on the Events channel when the diff --git a/internal/client/rest.go b/internal/client/rest.go index 78ac4ba..a5e8951 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -316,6 +316,15 @@ type SubagentEntry struct { LastTool string `json:"last_tool,omitempty"` DurationSeconds float64 `json:"duration_seconds,omitempty"` TokensUsed int `json:"tokens_used,omitempty"` + + // wire v2 — omitted when the engine predates it + Profile string `json:"profile,omitempty"` + MaxRisk string `json:"max_risk,omitempty"` + BudgetSeconds int `json:"budget_seconds,omitempty"` + BudgetIterations int `json:"budget_iterations,omitempty"` + CostUSD float64 `json:"cost_usd,omitempty"` + BudgetCostUSD float64 `json:"budget_cost_usd,omitempty"` + Artifacts []StateArtifact `json:"artifacts,omitempty"` } // Subagents fetches the sub-agent registry snapshot, optionally filtered by diff --git a/internal/tui/approval_expiry.go b/internal/tui/approval_expiry.go index 73f1003..91317e9 100644 --- a/internal/tui/approval_expiry.go +++ b/internal/tui/approval_expiry.go @@ -108,7 +108,7 @@ func (m *Model) handleApprovalExpiry(now time.Time) tea.Cmd { } else { m.status = "thinking" } - if len(m.approvals) == 0 || m.approvals[0] != oldHead { + if len(m.approvals) == 0 || m.approvals[0].ID != oldHead.ID { m.resetApprovalInput() m.relayout() } diff --git a/internal/tui/events.go b/internal/tui/events.go index b7b9627..260e1ca 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -120,6 +120,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) { steps[j].isErr = looksLikeError(steps[j].result) if steps[j].subagent { steps[j].resultCard = parseAgentResult(ev.Data) + if rc := steps[j].resultCard; rc != nil && rc.denialsTotal > 0 { + note := fmt.Sprintf("sub-agent · %d denied op", rc.denialsTotal) + if rc.denialsTotal > 1 { + note += "s" + } + m.addTransientNote(note + " — see the result card") + } } if !steps[j].started.IsZero() { steps[j].dur = time.Since(steps[j].started) @@ -602,7 +609,7 @@ func (m *Model) swarmVerdict(msg *message) string { } case a.status == "success": ok++ - case a.status == "partial": + case a.status == "partial", a.status == "budget_exhausted": partial++ case a.status == "error": failed++ diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index a69fd3e..1ce4fe4 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -610,6 +610,9 @@ func (m *Model) agentRowsRender(w int) []string { if e.DurationSeconds > 0 { detail += fmt.Sprintf(" · %.1fs", e.DurationSeconds) } + if e.CostUSD > 0 { + detail += " · " + fmtCost(e.CostUSD) + } budget := w - 2 - lipgloss.Width(detail) label := agentStatusGlyph(e.Phase, e.Status) + " " + goal prefix, lab := " ", th.acItem.Render(truncate(label, budget)) @@ -624,12 +627,15 @@ func (m *Model) agentRowsRender(w int) []string { // agentStatusGlyph mirrors the live-card glyph set. func agentStatusGlyph(phase, status string) string { if phase != "finished" { + if phase == "queued" { + return "◌" + } return "⟳" } switch status { case "success": return "✓" - case "partial": + case "partial", "budget_exhausted": return "◐" case "error": return "✗" @@ -719,6 +725,42 @@ func (m *Model) mgmtDetailLines(w int) []string { if !e.FinishedAt.IsZero() { out = append(out, th.acDetail.Render("finished "+e.FinishedAt.String())) } + if e.Profile != "" || e.MaxRisk != "" { + var trust []string + if e.Profile != "" { + trust = append(trust, "profile="+sanitize(e.Profile)) + } + if e.MaxRisk != "" { + trust = append(trust, "risk="+sanitize(e.MaxRisk)) + } + out = append(out, th.acDetail.Render(strings.Join(trust, " · "))) + } + if e.BudgetSeconds > 0 || e.BudgetIterations > 0 || e.BudgetCostUSD > 0 { + var b []string + if e.BudgetSeconds > 0 { + b = append(b, fmt.Sprintf("%ds", e.BudgetSeconds)) + } + if e.BudgetIterations > 0 { + b = append(b, fmt.Sprintf("%d it", e.BudgetIterations)) + } + if e.BudgetCostUSD > 0 { + b = append(b, strings.TrimPrefix(fmtCost(e.BudgetCostUSD), "~")) + } + out = append(out, th.acDetail.Render("budget "+strings.Join(b, " · "))) + } + if e.CostUSD > 0 { + out = append(out, th.acDetail.Render("cost "+fmtCost(e.CostUSD))) + } + if len(e.Artifacts) > 0 { + out = append(out, th.acDetail.Render(fmt.Sprintf("%d artifacts", len(e.Artifacts)))) + for _, art := range e.Artifacts { + al := "⎘ " + sanitize(art.ID) + if art.Path != "" { + al += " · " + sanitize(art.Path) + } + out = append(out, th.acDetail.Render(al)) + } + } case panelTools: r := m.toolSelected() if r == nil { diff --git a/internal/tui/subagent_wire_v2_test.go b/internal/tui/subagent_wire_v2_test.go new file mode 100644 index 0000000..485fe51 --- /dev/null +++ b/internal/tui/subagent_wire_v2_test.go @@ -0,0 +1,163 @@ +package tui + +import ( + "strings" + "testing" + + "github.com/BackendStack21/bodek/internal/client" +) + +// v2Fixture: a delegate step whose manifest declares one identity, followed +// by a started frame carrying the full wire-v2 block — wire truth must win. +func v2Fixture(t *testing.T) *Model { + t.Helper() + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", + Data: `{"tasks":[{"goal":"manifest goal","profile":"fast","max_risk":"system_write"}]}`}) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "started", Status: "running", + Goal: "wire goal", Profile: "default", MaxRisk: "local_write", + BudgetSeconds: 1800, BudgetIterations: 15}) + return m +} + +// TestWireIdentityBeatsManifest: started-frame identity (effective values) +// overrides the delegate arg's requested values; budgets ride along. +func TestWireIdentityBeatsManifest(t *testing.T) { + m := v2Fixture(t) + s := stateStep(t, m) + card := s.agents[0] + if card.goal != "wire goal" { + t.Errorf("goal = %q, want wire truth", card.goal) + } + if card.profile != "default" || card.maxRisk != "local_write" { + t.Errorf("identity = %q/%q, want default/local_write", card.profile, card.maxRisk) + } + if card.budgetS != 1800 || card.budgetIt != 15 { + t.Errorf("budgets = %d/%d, want 1800/15", card.budgetS, card.budgetIt) + } + if badge := cardTrustBadge(card); badge != "profile=default · risk=local_write" { + t.Errorf("badge = %q", badge) + } + if line := agentCardLine(card); !strings.Contains(line, "wire goal") { + t.Errorf("card line missing wire goal: %q", line) + } +} + +// TestQueuedPhase: queued frames create ◌ cards that count separately in the +// rollup, suppress elapsed, satisfy pending slots, and upgrade on started. +func TestQueuedPhase(t *testing.T) { + m := newTestModel() + m.msgs = append(m.msgs, message{role: roleAsst, streaming: true}) + m.curIdx = 0 + m.busy = true + m.handleEvent(client.Event{Type: "tool_call", Name: "delegate_tasks", + Data: `{"tasks":["a","b","c"]}`}) + for idx := 0; idx < 3; idx++ { + m.handleEvent(client.Event{Type: "subagent_state", TaskID: string(rune('a' + idx)), TaskIdx: idx, Phase: "queued", Status: "queued"}) + } + s := stateStep(t, m) + if len(s.agents) != 3 || s.agents[0].glyph() != "◌" { + t.Fatalf("queued cards wrong: %s %s %s", s.agents[0].glyph(), s.agents[1].glyph(), s.agents[2].glyph()) + } + if line := agentCardLine(s.agents[0]); !strings.Contains(line, "queued") || strings.Contains(line, "· step") { + t.Errorf("queued line wrong: %q", line) + } + if r := agentRollup(s); r != "0/3 agents · 3 queued" { + t.Errorf("rollup = %q", r) + } + if p := s.pendingAgentLines(); len(p) != 0 { + t.Errorf("queued cards should satisfy pending slots: %q", p) + } + + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "a", TaskIdx: 0, Phase: "started", Status: "running", Step: 1}) + s = stateStep(t, m) + if s.agents[0].glyph() != "⟳" { + t.Errorf("started card still queued glyph: %q", s.agents[0].glyph()) + } + if r := agentRollup(s); r != "0/3 agents · 2 queued" { + t.Errorf("rollup after start = %q", r) + } +} + +// TestBudgetAndCostRender: budgets turn "9 it" into "it 9/15" and pair the +// elapsed with its cap; cost renders as an estimate, capped form included. +func TestBudgetAndCostRender(t *testing.T) { + m := v2Fixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "active", Status: "running", + Step: 9, Iterations: 9, CostUSD: 0.0421, BudgetCostUSD: 0.5}) + s := stateStep(t, m) + line := agentCardLine(s.agents[0]) + for _, want := range []string{"it 9/15", "/30m", "~$0.0421/$0.5"} { + if !strings.Contains(line, want) { + t.Errorf("line missing %q: %q", want, line) + } + } + + // Queued cards have no elapsed to pair. + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t2", TaskIdx: 1, Phase: "queued", Status: "queued"}) + s = stateStep(t, m) + if line := agentCardLine(s.agents[1]); strings.Contains(line, "/30m") { + t.Errorf("queued line shows elapsed: %q", line) + } +} + +// TestResultEnvelopeV2: the framed result's artifacts, denials, and final +// cost parse and render; wire text is sanitized before display. +func TestResultEnvelopeV2(t *testing.T) { + data := `{"status":"partial","summary":"did ","files_changed":["a.go"], + "iterations":4,"tokens_used":900,"cost_usd":0.0125, + "artifacts":[{"schema":"odek.artifact-ref/v1","id":"a1","uri":"file:///tmp/a1.md","media_type":"text/markdown","size_bytes":2048}], + "denials":[{"tool":"shell","class":"system_write","reason":"protected path"}],"denials_total":2}` + r := parseAgentResult(data) + if r == nil { + t.Fatal("envelope not parsed") + } + if r.costUSD != 0.0125 || r.denialsTotal != 2 || len(r.artifacts) != 1 || len(r.denials) != 1 { + t.Fatalf("parsed envelope wrong: cost=%v denials=%d arts=%v denls=%v", + r.costUSD, r.denialsTotal, r.artifacts, r.denials) + } + if r.artifacts[0].URI != "file:///tmp/a1.md" || r.artifacts[0].SizeBytes == nil || *r.artifacts[0].SizeBytes != 2048 { + t.Fatalf("artifact ref wrong: %+v", r.artifacts[0]) + } + + m := newTestModel() + lines := agentResultLines(m, r, 200) + joined := strings.Join(lines, "\n") + for _, want := range []string{"~$0.0125", "⊘ 2 denied", "⎘ a1", "file:///tmp/a1.md", "(2k)", "shell (system_write)"} { + if !strings.Contains(joined, want) { + t.Errorf("result card missing %q:\n%s", want, joined) + } + } + if lines := strings.Split(joined, "\n"); len(lines) > 0 && strings.Contains(lines[0], "\n") { + t.Error("summary not collapsed to one line") + } +} + +// TestBudgetExhaustedStatus: the new terminal status renders as a partial — +// glyph ◐, counted in the swarm verdict's ◐ bucket. +func TestBudgetExhaustedStatus(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "subagent_state", TaskID: "t1", TaskIdx: 0, Phase: "finished", Status: "budget_exhausted"}) + s := stateStep(t, m) + if s.agents[0].glyph() != "◐" { + t.Errorf("glyph = %q, want ◐", s.agents[0].glyph()) + } + m.finalize() + if !strings.Contains(m.msgs[0].content, "swarm: 1 ◐") { + t.Errorf("verdict missing budget_exhausted: %q", m.msgs[0].content) + } +} + +// TestDenialsNote: a framed result carrying denials surfaces a transient +// note — the boundary hit must be visible even with the step collapsed. +func TestDenialsNote(t *testing.T) { + m := stateFixture(t) + m.handleEvent(client.Event{Type: "tool_result", Name: "delegate_tasks", Data: `{"status":"partial","summary":"partly done","denials":[{"tool":"shell","class":"system_write","reason":"protected path"}],"denials_total":2}`}) + got := strings.Join(m.notices, "\n") + if !strings.Contains(got, "2 denied") { + t.Errorf("denials note missing: %q", got) + } +} diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index 13875d4..cf34961 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -21,8 +21,8 @@ import ( type agentCard struct { taskID string idx int - phase string // started | active | finished - status string // running | success | partial | error | cancelled | timeout + phase string // queued | started | active | finished + status string // running | queued | success | partial | budget_exhausted | error | cancelled | timeout step int tool string iters int @@ -32,6 +32,15 @@ type agentCard struct { goal string // manifest goal excerpt (parent arg; "" when unknown) seen time.Time // first frame locally observed; drives the live elapsed lost bool // socket dropped while running: retired, never a ghost + + // wire v2 identity/budget/cost (omitempty on the wire; ""/0 = unreported) + profile string + maxRisk string + budgetS int + budgetIt int + costUSD float64 + budgetCostUSD float64 + artifacts []client.StateArtifact } // finished reports whether the card reached a terminal state. @@ -41,15 +50,18 @@ func (a *agentCard) finished() bool { return a.phase == "finished" } // odek's status framing (user cancel and deadline timeout never conflate). func (a *agentCard) glyph() string { if !a.finished() { - if a.lost { + switch { + case a.lost: return "×" // orphaned by a disconnect: dead, not spinning + case a.phase == "queued": + return "◌" // accepted by the engine, not spawned yet } return "⟳" } switch a.status { case "success": return "✓" - case "partial": + case "partial", "budget_exhausted": return "◐" case "error": return "✗" @@ -119,6 +131,33 @@ func (m *Model) attachSubState(i int, ev client.Event) bool { card.iters = ev.Iterations card.tokens = ev.TokensUsed card.durS = ev.DurationSeconds + // Wire-v2 identity/budget/cost: non-empty wire values overwrite the + // manifest-seeded guesses (queued frames carry the requested profile + // and risk; started+ frames carry the effective ones). + if ev.Goal != "" { + card.goal = collapse(ev.Goal) + } + if ev.Profile != "" { + card.profile = collapse(ev.Profile) + } + if ev.MaxRisk != "" { + card.maxRisk = collapse(ev.MaxRisk) + } + if ev.BudgetSeconds > 0 { + card.budgetS = ev.BudgetSeconds + } + if ev.BudgetIterations > 0 { + card.budgetIt = ev.BudgetIterations + } + if ev.CostUSD > 0 { + card.costUSD = ev.CostUSD + } + if ev.BudgetCostUSD > 0 { + card.budgetCostUSD = ev.BudgetCostUSD + } + if len(ev.Artifacts) > 0 { + card.artifacts = ev.Artifacts + } if card.lost { card.lost = false // frames resumed after a reconnect: alive again } @@ -141,9 +180,12 @@ func agentCardLine(a *agentCard) string { var b strings.Builder fmt.Fprintf(&b, "%s SA%d", a.glyph(), a.idx+1) if !a.finished() { - if a.lost { + switch { + case a.lost: b.WriteString(" · lost on disconnect") - } else { + case a.phase == "queued": + b.WriteString(" · queued") + default: if a.step > 0 { fmt.Fprintf(&b, " · step %d", a.step) } @@ -152,8 +194,13 @@ func agentCardLine(a *agentCard) string { } if !a.seen.IsZero() { // Client-side elapsed: state frames arrive in bursts, so the - // server-reported duration freezes between them. - b.WriteString(" · " + formatStepDur(time.Since(a.seen))) + // server-reported duration freezes between them. Paired with + // the wall-clock cap when the engine declares one. + elapsed := formatStepDur(time.Since(a.seen)) + if a.budgetS > 0 { + elapsed += "/" + formatStepDur(time.Duration(a.budgetS)*time.Second) + } + b.WriteString(" · " + elapsed) } } } else if a.status != "" && a.status != "success" { @@ -163,7 +210,11 @@ func agentCardLine(a *agentCard) string { b.WriteString(" · stop sent") } if a.iters > 0 { - fmt.Fprintf(&b, " · %d it", a.iters) + if a.budgetIt > 0 { + fmt.Fprintf(&b, " · it %d/%d", a.iters, a.budgetIt) + } else { + fmt.Fprintf(&b, " · %d it", a.iters) + } } if a.tokens > 0 { b.WriteString(" · " + human(a.tokens) + " tok") @@ -171,6 +222,13 @@ func agentCardLine(a *agentCard) string { if a.durS > 0 { b.WriteString(" · " + formatStepDur(time.Duration(a.durS*float64(time.Second)))) } + if a.costUSD > 0 { + c := fmtCost(a.costUSD) + if a.budgetCostUSD > 0 { + c += "/" + strings.TrimPrefix(fmtCost(a.budgetCostUSD), "~") + } + b.WriteString(" · " + c) + } // The goal renders last on purpose: right-edge truncation on narrow // terminals eats the garnish before the vitals. if a.goal != "" { @@ -185,7 +243,7 @@ func agentRollup(s *step) string { if len(s.agents) == 0 { return "" } - done, failed, tokens := 0, 0, 0 + done, failed, queued, tokens := 0, 0, 0, 0 for _, a := range s.agents { if a.finished() { done++ @@ -193,18 +251,43 @@ func agentRollup(s *step) string { if a.failed() { failed++ } + if a.phase == "queued" { + queued++ + } tokens += a.tokens } rollup := fmt.Sprintf("%d/%d agents", done, len(s.agents)) if failed > 0 { rollup += fmt.Sprintf(" · %d ✗", failed) } + if queued > 0 { + rollup += fmt.Sprintf(" · %d queued", queued) + } if tokens > 0 { rollup += " · " + human(tokens) + " tok" } return rollup } +// cardTrustBadge renders a card's wire-v2 trust line — the resolved profile +// and the effective risk ceiling the engine reports. "" when unreported. +func cardTrustBadge(a *agentCard) string { + var parts []string + if a.profile != "" { + parts = append(parts, "profile="+a.profile) + } + if a.maxRisk != "" { + parts = append(parts, "risk="+a.maxRisk) + } + return strings.Join(parts, " · ") +} + +// fmtCost renders an estimated cost: "~$0.0421" — shortest exact decimal +// representation. Absent costs never render as $0 upstream. +func fmtCost(v float64) string { + return "~$" + strconv.FormatFloat(v, 'f', -1, 64) +} + // ── delegate manifest (per-task identity from the parent's arg) ─────────────── // taskSlot is one delegate_tasks argument entry, parsed at tool-call time: @@ -213,6 +296,7 @@ func agentRollup(s *step) string { type taskSlot struct { goal string // collapsed excerpt, ≤32 runes ("" when absent) profile string // requested profile id, as sent ("" when unset) + maxRisk string // requested risk ceiling, as sent ("" when unset) } // parseDelegateManifest extracts per-task slots from a delegate_tasks JSON @@ -481,11 +565,15 @@ func (m *Model) stopByLabel(args string) tea.Cmd { // tool_result (headline capped at 2048 by serve): status, summary, changed // files, and usage — parsed tolerantly, rendered as a card in the details. type agentResult struct { - status string - summary string - files []string - tokens int - iters int + status string + summary string + files []string + tokens int + iters int + costUSD float64 + artifacts []client.ResultArtifact + denials []client.ResultDenial + denialsTotal int } // parseAgentResult extracts the framed-result envelope from delegate tool @@ -498,11 +586,15 @@ func parseAgentResult(data string) *agentResult { return nil } var env struct { - Status string `json:"status"` - Summary string `json:"summary"` - FilesChanged []string `json:"files_changed"` - TokensUsed int `json:"tokens_used"` - Iterations int `json:"iterations"` + Status string `json:"status"` + Summary string `json:"summary"` + FilesChanged []string `json:"files_changed"` + TokensUsed int `json:"tokens_used"` + Iterations int `json:"iterations"` + CostUSD float64 `json:"cost_usd"` + Artifacts []client.ResultArtifact `json:"artifacts"` + Denials []client.ResultDenial `json:"denials"` + DenialsTotal int `json:"denials_total"` } if err := json.Unmarshal([]byte(obj), &env); err != nil { return nil @@ -510,7 +602,8 @@ func parseAgentResult(data string) *agentResult { if env.Status == "" || env.Summary == "" { return nil } - r := &agentResult{status: env.Status, summary: collapse(env.Summary), tokens: env.TokensUsed, iters: env.Iterations} + r := &agentResult{status: env.Status, summary: collapse(env.Summary), tokens: env.TokensUsed, iters: env.Iterations, + costUSD: env.CostUSD, artifacts: env.Artifacts, denials: env.Denials, denialsTotal: env.DenialsTotal} for _, f := range env.FilesChanged { if f = collapse(f); f != "" { r.files = append(r.files, f) @@ -556,7 +649,8 @@ func firstJSONObject(s string) string { } // agentResultLines renders the structured result card for the expanded step -// details: summary, status/usage head, then one line per changed file. +// details: summary, status/usage head, changed files, then artifact refs and +// the policy denials the run hit. func agentResultLines(m *Model, r *agentResult, budget int) []string { th := m.th parts := []string{r.status, fmt.Sprintf("%d files", len(r.files))} @@ -566,6 +660,12 @@ func agentResultLines(m *Model, r *agentResult, budget int) []string { if r.tokens > 0 { parts = append(parts, human(r.tokens)+" tok") } + if r.costUSD > 0 { + parts = append(parts, fmtCost(r.costUSD)) + } + if r.denialsTotal > 0 { + parts = append(parts, fmt.Sprintf("⊘ %d denied", r.denialsTotal)) + } head := strings.Join(parts, " · ") style := th.stepRes if r.status == "error" { @@ -576,5 +676,29 @@ func agentResultLines(m *Model, r *agentResult, budget int) []string { for _, f := range r.files { lines = append(lines, th.stepRes.Render(truncate("· "+f, budget))) } + for _, art := range r.artifacts { + al := "⎘ " + collapse(art.ID) + if art.URI != "" { + al += " · " + collapse(art.URI) + } + if art.SizeBytes != nil && *art.SizeBytes > 0 { + al += fmt.Sprintf(" (%s)", human(int(*art.SizeBytes))) + } + lines = append(lines, th.stepRes.Render(truncate(al, budget))) + } + for i, d := range r.denials { + if i == 4 { + lines = append(lines, th.stepRes.Render(truncate(fmt.Sprintf("… +%d more", len(r.denials)-4), budget))) + break + } + dl := "⊘ " + collapse(d.Tool) + if d.Class != "" { + dl += " (" + collapse(d.Class) + ")" + } + if d.Reason != "" { + dl += " — " + collapse(d.Reason) + } + lines = append(lines, th.stepRes.Render(truncate(dl, budget))) + } return lines } diff --git a/internal/tui/view.go b/internal/tui/view.go index 8123126..4712cd3 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -803,11 +803,24 @@ func (m *Model) renderStep(s step, streaming bool, msgIdx, stepIdx, startLine in var details []string for _, a := range s.agents { line := agentCardLine(a) + styled := th.stepRes.Render(truncate(line, detailBudget)) if a.failed() { - details = append(details, th.stepErr.Render(truncate(line, detailBudget))) - continue + styled = th.stepErr.Render(truncate(line, detailBudget)) + } + if badge := cardTrustBadge(a); badge != "" { + styled += th.stepArg.Render(" " + badge) + } + details = append(details, styled) + for _, art := range a.artifacts { + al := "⎘ " + art.ID + if art.Path != "" { + al += " · " + art.Path + } + if art.Bytes > 0 { + al += fmt.Sprintf(" (%s)", human(int(art.Bytes))) + } + details = append(details, th.stepArg.Render(truncate(collapse(al), detailBudget))) } - details = append(details, th.stepRes.Render(truncate(line, detailBudget))) } for _, ln := range s.pendingAgentLines() { details = append(details, th.stepArg.Render(truncate(ln, detailBudget))) From 2c57d70e482bb0f9a68e7274ceaec375e38b2a9a Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Tue, 1 Sep 2026 07:38:53 +0200 Subject: [PATCH 2/2] fix(tui): seed card identity from the manifest's profile and max_risk MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit taskSlot.maxRisk was declared but never populated (caught by CI's golangci-lint: unused field). Parse max_risk from the delegate arg and seed all three identity fields at card creation — the pre-v2 fallback that newer frames overwrite with effective values. --- internal/tui/subagents.go | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/internal/tui/subagents.go b/internal/tui/subagents.go index cf34961..f1a0a39 100644 --- a/internal/tui/subagents.go +++ b/internal/tui/subagents.go @@ -111,10 +111,12 @@ func (m *Model) attachSubState(i int, ev client.Event) bool { card := s.card(ev.TaskID) if card == nil { card = &agentCard{taskID: ev.TaskID, idx: ev.TaskIdx, status: "running", seen: time.Now()} - // Seed identity from the delegate manifest: goal and profile ride - // the parent's tool_call arg, never the wire frames. + // Seed identity from the delegate manifest (the pre-v2 fallback): + // goals/profiles/risk ride the parent's tool_call arg, never the + // old wire frames. Newer frames overwrite with effective values. if ev.TaskIdx >= 0 && ev.TaskIdx < len(s.manifest) { - card.goal = s.manifest[ev.TaskIdx].goal + slot := s.manifest[ev.TaskIdx] + card.goal, card.profile, card.maxRisk = slot.goal, slot.profile, slot.maxRisk } s.agents = append(s.agents, card) } @@ -314,9 +316,10 @@ func parseDelegateManifest(data string) []taskSlot { var obj struct { Goal string `json:"goal"` Profile string `json:"profile"` + MaxRisk string `json:"max_risk"` } if err := json.Unmarshal(raw, &obj); err == nil { - slots = append(slots, taskSlot{goal: excerptGoal(obj.Goal), profile: collapse(obj.Profile)}) + slots = append(slots, taskSlot{goal: excerptGoal(obj.Goal), profile: collapse(obj.Profile), maxRisk: collapse(obj.MaxRisk)}) } } var env struct {