Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <SA#>`, 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.
Expand Down Expand Up @@ -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):
Expand Down
38 changes: 37 additions & 1 deletion internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 9 additions & 0 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion internal/tui/approval_expiry.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
9 changes: 8 additions & 1 deletion internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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++
Expand Down
44 changes: 43 additions & 1 deletion internal/tui/mgmt.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand All @@ -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 "✗"
Expand Down Expand Up @@ -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 {
Expand Down
163 changes: 163 additions & 0 deletions internal/tui/subagent_wire_v2_test.go
Original file line number Diff line number Diff line change
@@ -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 <script>things</script>","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)
}
}
Loading