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
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,13 @@ feat(tui): compact tool steps with Ctrl+E details toggle
opened on the local send path. The card carries the `systemWake` marker
(renders `⬡ odek · wake`); wake turns are never rendered as user
messages, and a wake frame arriving during an operator turn opens
nothing.
nothing. If the stamped frame is missed (reconnect race, wire quirk),
`ensureWireTurn` lazily opens the card from the first streamed event —
idle-plus-stream proves a server-initiated turn, since every operator
turn starts with a local send — keeping the wake marker when `bg_wake`
armed it, healing the (normally unreachable) busy-without-card state,
and labelling a stampless stream as a plain remote card instead of
dropping it.

## Workflow rules for agents

Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,6 +294,10 @@ own front-end settings are separate; see [Configuration](#configuration).
idle (odek ≥ v1.40), the engine wakes the model on its own; bodek opens
the turn from the wire, marks the card `⬡ odek · wake`, and streams the
model's report like any other turn — never rendered as a user message.
The open is self-healing: even if the wire's wake stamp is missed, the
first streamed event opens the card (wake-marked when a `bg_wake` note
preceded it, a plain remote card otherwise) instead of the loop
silently vanishing from the transcript.

---

Expand Down
43 changes: 40 additions & 3 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// The full text is stored; only the rendered excerpt is capped (see
// maxThinkingLen), so expandAll can unfold the complete block once the
// turn finalizes.
m.ensureWireTurn()
if i := m.cur(); i >= 0 {
msg := &m.msgs[i]
if n := len(msg.items); n > 0 && msg.items[n-1].thinking {
Expand All @@ -79,6 +80,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// the timeline as its own reply segment — appended to the open one,
// or opened fresh after reasoning/tools — so each think→reply cycle
// renders independently (appendReply keeps msg.content in sync).
m.ensureWireTurn()
if i := m.cur(); i >= 0 {
appendReply(&m.msgs[i], sanitize(ev.Content))
m.msgs[i].streaming = true
Expand All @@ -94,6 +96,7 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
}
}
nm := collapse(ev.Name) // tool names are wire-borne; collapse before anything renders them
m.ensureWireTurn()
if i := m.cur(); i >= 0 {
m.msgs[i].steps = append(m.msgs[i].steps,
step{name: nm, arg: arg, subagent: isSubagent(nm), started: time.Now()})
Expand Down Expand Up @@ -352,7 +355,10 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
case "bg_wake":
// odek ≥ v1.40 enqueued a wake turn for a finished background job:
// the stamped session frame that follows opens the card; this note
// gives the operator the context for the unprompted activity.
// gives the operator the context for the unprompted activity. The
// flag also arms the lazy marker (ensureWireTurn) so the wake keeps
// its identity even when the stamped frame is missed.
m.wakeArmed = true
m.addTransientNote("background job finished · agent waking")

case "bg_job":
Expand Down Expand Up @@ -431,12 +437,42 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
// path. The card carries the systemWake marker so the unprompted turn is
// never mistaken for an operator exchange.
func (m *Model) openWakeTurn() {
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: true})
m.beginWireTurn(true)
}

// ensureWireTurn lazily opens a streaming card when events arrive for a turn
// that has no card: in bodek every operator turn begins with a local send,
// so idle-plus-stream proves a server-initiated turn whose stamped session
// frame was missed (reconnect race, wire quirk). It also heals the corrupt
// busy-without-card state, which the normal flow can never produce —
// sendPrompt/openWakeTurn set both atomically and done/error clear both.
// Without this, thinking/token/tool_call would drop silently while m.status
// still mutated, leaving a phantom "running <tool>" line over an empty
// transcript and sub-agent frames degraded to transient notes.
func (m *Model) ensureWireTurn() {
if m.cur() >= 0 {
return // live operator/wake turn: keep absorbing
}
wake := m.wakeArmed
m.busy = false // heal corrupt state before reopening
m.beginWireTurn(wake)
}

// beginWireTurn appends the streaming card and arms the busy turn state
// shared by the stamped-frame path (openWakeTurn) and the lazy path
// (ensureWireTurn). wake decides the systemWake marker and status line.
func (m *Model) beginWireTurn(wake bool) {
m.msgs = append(m.msgs, message{role: roleAsst, streaming: true, systemWake: wake})
m.curIdx = len(m.msgs) - 1
m.busy = true
m.cancelAck = false // a wake run's errors are real errors again
m.skillSuggest = nil // the suggestion's window closed with the last turn
m.status = "waking for bg job"
m.wakeArmed = false // consumed: the marker lives on the card now
if wake {
m.status = "waking for bg job"
} else {
m.status = "remote turn"
}
m.runStart = time.Now()
if m.sessionStart.IsZero() {
m.sessionStart = m.runStart
Expand Down Expand Up @@ -637,6 +673,7 @@ func (m *Model) finalize() {
m.closeTurn(&m.msgs[i])
}
m.curIdx = -1
m.wakeArmed = false // the window closed with the turn
m.clearStickyNotes()
}

Expand Down
1 change: 1 addition & 0 deletions internal/tui/input.go
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,7 @@ func (m *Model) sendPrompt(text string) tea.Cmd {
m.busy = true
m.cancelAck = false // a fresh run's errors are real errors again
m.skillSuggest = nil // the suggestion's window closed with the turn
m.wakeArmed = false // a local send is never a wake turn
m.status = "thinking"
m.runStart = time.Now()
if m.sessionStart.IsZero() {
Expand Down
13 changes: 7 additions & 6 deletions internal/tui/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,13 @@ type Model struct {
sp spinner.Model
glam *glamour.TermRenderer

msgs []message
curIdx int // index of the streaming assistant message, -1 when idle
busy bool
runStart time.Time
lastTool string
lastArg string
msgs []message
curIdx int // index of the streaming assistant message, -1 when idle
busy bool
wakeArmed bool // bg_wake seen but its turn not carded yet: arms the lazy wake marker
runStart time.Time
lastTool string
lastArg string

approvals []client.Event // pending approval queue — odek runs parallel tools, so requests FIFO
apprDeadlines []time.Time // per-approval expiry, stamped on arrival (parallel to approvals)
Expand Down
138 changes: 138 additions & 0 deletions internal/tui/wake_resilience_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
package tui

import (
"testing"

"github.com/BackendStack21/bodek/internal/client"
)

// The stamped wake session frame can be missed (lost on a reconnect race, a
// future wire quirk) — the wake turn's streamed events then find no open card
// and would drop silently. In bodek every operator turn starts with a local
// send, so idle + incoming stream PROVES a server-initiated turn: the first
// streaming event must open a card from the wire.

// Idle + streaming with no bg_wake seen: a plain remote card opens (a turn
// started from another client on this session), not wake-marked.
func TestIdleStreamingOpensRemoteCard(t *testing.T) {
m := newTestModel()
feed := []client.Event{
{Type: "thinking", Content: "checking job output"},
{Type: "token", Content: "PR #176 is green"},
{Type: "tool_call", Name: "delegate_tasks", Data: `{"tasks":[]}`},
}
for _, ev := range feed {
m.handleEvent(ev)
}
i := m.cur()
if i < 0 {
t.Fatal("streaming events while idle did not open a card — loop dropped")
}
msg := m.msgs[i]
if !msg.streaming {
t.Error("lazy card is not streaming")
}
if msg.systemWake {
t.Error("no bg_wake seen: card must not carry the wake marker")
}
if len(msg.items) == 0 {
t.Error("reasoning/reply items did not land on the lazy card")
}
if len(msg.steps) != 1 || msg.steps[0].name != "delegate_tasks" {
t.Errorf("tool step did not land: %+v", msg.steps)
}

// The turn finalizes like any other.
m.handleEvent(client.Event{Type: "done"})
if m.busy {
t.Error("done after lazy-open left busy set")
}
if m.status != "ready" {
t.Errorf("status = %q after done, want ready", m.status)
}
if m.cur() >= 0 {
t.Error("done after lazy-open left the card open")
}
}

// bg_wake armed the marker before the stamp was missed: the lazy card keeps
// the systemWake identity and renders as a wake turn, never as user text.
func TestWakeArmedStreamingOpensWakeCard(t *testing.T) {
m := newTestModel()
m.handleEvent(client.Event{Type: "bg_wake"})
m.handleEvent(client.Event{Type: "thinking", Content: "resuming after job"})
i := m.cur()
if i < 0 {
t.Fatal("wake-armed streaming did not open a card")
}
if !m.msgs[i].systemWake {
t.Error("bg_wake seen: lazy card must carry the systemWake marker")
}
if m.msgs[i].role != roleAsst {
t.Errorf("wake card role = %v, want assistant (never renders as a user message)", m.msgs[i].role)
}
m.handleEvent(client.Event{Type: "token", Content: "done"})
m.handleEvent(client.Event{Type: "done"})
}

// busy-without-card is unreachable through the normal flow (sendPrompt and
// openWakeTurn both set the card atomically; done/error finalize together) —
// if it is ever observed, the state is corrupt and must heal, not deadlock.
func TestCorruptBusyWithoutCardHeals(t *testing.T) {
m := newTestModel()
m.busy = true // corrupt: no open card
m.curIdx = -1
m.handleEvent(client.Event{Type: "thinking", Content: "wake stream"})
if i := m.cur(); i < 0 {
t.Fatal("corrupt busy state deadlocked the card open")
}
if !m.busy {
t.Error("healed turn must be busy again while streaming")
}
m.handleEvent(client.Event{Type: "done"})
if m.busy || m.status != "ready" {
t.Errorf("post-heal done: busy=%v status=%q", m.busy, m.status)
}
}

// A live operator turn keeps absorbing the stream — lazy-open must never
// stack a second card on top of it (belt for the stamped-frame guard).
func TestLazyOpenNeverStacksOnOperatorTurn(t *testing.T) {
m := newTestModel()
m.sendPrompt("what time is it?")
before := len(m.msgs)
m.handleEvent(client.Event{Type: "thinking", Content: "thinking"})
m.handleEvent(client.Event{Type: "tool_call", Name: "shell", Data: `{"cmd":"date"}`})
if len(m.msgs) != before {
t.Errorf("lazy-open stacked a card on a live operator turn: %d -> %d", before, len(m.msgs))
}
if i := m.cur(); i < 0 {
t.Fatal("operator card lost")
}
if n := len(m.msgs[m.cur()].steps); n != 1 {
t.Errorf("operator turn steps = %d, want 1", n)
}
}

// wakeArmed retires with the turn: a later idle stream is not mislabelled as
// a wake, and a fresh bg_wake re-arms it.
func TestWakeArmedRetiresAndRearms(t *testing.T) {
m := newTestModel()
m.handleEvent(client.Event{Type: "bg_wake"})
m.handleEvent(client.Event{Type: "thinking", Content: "wake turn"})
m.handleEvent(client.Event{Type: "done"})
m.handleEvent(client.Event{Type: "thinking", Content: "unrelated remote turn"})
if i := m.cur(); i < 0 {
t.Fatal("second stream did not open a card")
} else if m.msgs[i].systemWake {
t.Error("wakeArmed leaked past turn end — card mislabelled as wake")
}
m.handleEvent(client.Event{Type: "done"})
m.handleEvent(client.Event{Type: "bg_wake"})
m.handleEvent(client.Event{Type: "thinking", Content: "second wake"})
if i := m.cur(); i < 0 {
t.Fatal("re-armed stream did not open a card")
} else if !m.msgs[i].systemWake {
t.Error("fresh bg_wake did not re-arm the wake marker")
}
}