diff --git a/AGENTS.md b/AGENTS.md index 74303cc..9c14148 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 0116369..023e79d 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/internal/tui/events.go b/internal/tui/events.go index d862a20..030e636 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -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 { @@ -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 @@ -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()}) @@ -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": @@ -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 " 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 @@ -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() } diff --git a/internal/tui/input.go b/internal/tui/input.go index 765b6ea..71b46ee 100644 --- a/internal/tui/input.go +++ b/internal/tui/input.go @@ -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() { diff --git a/internal/tui/model.go b/internal/tui/model.go index fe57940..660f968 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -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) diff --git a/internal/tui/wake_resilience_test.go b/internal/tui/wake_resilience_test.go new file mode 100644 index 0000000..dae3ebe --- /dev/null +++ b/internal/tui/wake_resilience_test.go @@ -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") + } +}