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
12 changes: 5 additions & 7 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -131,13 +131,11 @@ 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. 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.
nothing. With odek's `turn_started` protocol the dedicated frame is the
primary signal for every turn (wake → wake-marked card, foreign
operator turn → plain remote card); the stamped session frame and
`ensureWireTurn`'s lazy open (first streamed event while idle) remain
as fallbacks, so a turn must be missed by all three paths to drop.

## Workflow rules for agents

Expand Down
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -294,10 +294,11 @@ 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.
Card opening is self-healing: the `turn_started` announcement is the
primary signal (wake-marked for system turns, a plain remote card for
turns prompted from other clients), with the wake stamp and a
first-streamed-event fallback behind it — a turn cannot silently
vanish from the transcript.

---

Expand Down
8 changes: 8 additions & 0 deletions internal/client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,14 @@ type Event struct {
// ≥ v1.40); absent on operator turns.
SystemInitiated bool `json:"system_initiated,omitempty"`

// turn_started (≥ the turn_started protocol): identity and provenance
// of every turn — initiated is "system" for wake turns, "operator"
// otherwise. turn_id also annotates thinking/token/tool_call/
// tool_result/done/error while the turn is live (R3) for mid-turn
// attribution; the TUI currently keys only off turn_started itself.
TurnID string `json:"turn_id,omitempty"`
Initiated string `json:"initiated,omitempty"`

// done — token economics for the turn and the session. ContextTokens is
// cumulative prompt tokens across all LLM calls of the run (the live
// window fill is the delta between consecutive reports); the Session*
Expand Down
49 changes: 49 additions & 0 deletions internal/client/turn_started_decode_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package client

import (
"encoding/json"
"testing"
)

// turn_started (≥ the turn_started protocol): every turn announces itself
// with an identity and provenance; turn_id also annotates streamed frames
// (R3). The decoder must surface both fields.
func TestTurnStartedDecode(t *testing.T) {
var ev Event
err := json.Unmarshal([]byte(`{
"type": "turn_started",
"turn_id": "t_0123abcd",
"session_id": "s1",
"initiated": "system",
"model": "glm-5.3-flash"
}`), &ev)
if err != nil {
t.Fatal(err)
}
if ev.Type != "turn_started" {
t.Errorf("Type = %q", ev.Type)
}
if ev.TurnID != "t_0123abcd" {
t.Errorf("TurnID = %q", ev.TurnID)
}
if ev.SessionID != "s1" {
t.Errorf("SessionID = %q", ev.SessionID)
}
if ev.Initiated != "system" {
t.Errorf("Initiated = %q", ev.Initiated)
}
if ev.Model != "glm-5.3-flash" {
t.Errorf("Model = %q", ev.Model)
}
}

// R3: streamed frames carry turn_id while a turn is live.
func TestTurnIDOnStreamedFrames(t *testing.T) {
var ev Event
if err := json.Unmarshal([]byte(`{"type":"tool_call","name":"shell","data":"{}","turn_id":"t_0123abcd"}`), &ev); err != nil {
t.Fatal(err)
}
if ev.TurnID != "t_0123abcd" {
t.Errorf("TurnID on streamed frame = %q", ev.TurnID)
}
}
13 changes: 13 additions & 0 deletions internal/tui/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,19 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
m.openWakeTurn() // server-started turn: open the card from the wire
}

case "turn_started":
// odek ≥ the turn_started protocol announces EVERY turn right after
// the session frame (R1): initiated=system is a wake, operator a
// plain turn — including turns prompted from another client on this
// session, which now open a visible remote card instead of waiting
// for the lazy fallback. The guards make replays idempotent (R2);
// the stamped session frame and ensureWireTurn stay as fallbacks,
// so a turn must be missed by all three paths to drop.
if m.cur() >= 0 || m.busy {
break
}
m.beginWireTurn(ev.Initiated == "system")

case "thinking", "thinking_delta":
// Bulk reasoning and live streamed fragments (streaming on) share one
// path: append to the open reasoning block (the last timeline item
Expand Down
91 changes: 91 additions & 0 deletions internal/tui/turn_started_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
package tui

import (
"testing"

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

// odek ≥ the turn_started protocol announces EVERY turn right after the
// session frame: wake turns carry initiated=system, operator turns
// operator. The frame becomes the primary card-opening signal — the
// stamped session frame and the lazy ensureWireTurn fallback stay as
// belt-and-suspenders.

// A wake announcement opens the wake-marked streaming card from the wire.
func TestTurnStartedSystemOpensWakeCard(t *testing.T) {
m := newTestModel()
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_ab12", SessionID: "s1", Initiated: "system", Model: "glm"})

i := m.cur()
if i < 0 {
t.Fatal("turn_started(system) did not open a streaming card")
}
if !m.msgs[i].systemWake {
t.Error("system turn not marked systemWake")
}
if !m.busy {
t.Error("turn_started(system) did not set busy")
}
m.handleEvent(client.Event{Type: "thinking", Content: "on the job"})
if i := m.cur(); i < 0 || len(m.msgs[i].items) == 0 {
t.Fatal("streamed reasoning did not land on the turn_started card")
}
}

// A foreign operator turn (prompted from another client on this session)
// opens a plain remote card — visible, never wake-marked.
func TestTurnStartedOperatorOpensPlainRemoteCard(t *testing.T) {
m := newTestModel()
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_cd34", SessionID: "s1", Initiated: "operator", Model: "glm"})

i := m.cur()
if i < 0 {
t.Fatal("turn_started(operator) while idle did not open a card")
}
if m.msgs[i].systemWake {
t.Error("operator turn mislabelled as wake")
}
if !m.busy {
t.Error("turn_started(operator) did not set busy")
}
}

// R2 idempotency: a replayed turn_started must not stack a second card.
func TestTurnStartedIdempotent(t *testing.T) {
m := newTestModel()
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_ab12", Initiated: "system"})
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_ab12", Initiated: "system"})
if n := len(m.msgs); n != 1 {
t.Fatalf("replayed turn_started stacked cards: len(msgs) = %d", n)
}
if m.cur() != 0 {
t.Fatalf("cur = %d, want 0", m.cur())
}
}

// bodek's own send path already opened the card before the frame arrives —
// the announcement must be a no-op there.
func TestTurnStartedSuppressedOnOwnTurn(t *testing.T) {
m := newTestModel()
m.sendPrompt("what time is it?")
before := len(m.msgs)
m.handleEvent(client.Event{Type: "turn_started", TurnID: "t_ef56", Initiated: "operator"})
if len(m.msgs) != before {
t.Fatalf("own turn's turn_started stacked a card: %d -> %d", before, len(m.msgs))
}
}

// A wake announcement during a live operator turn opens nothing — odek
// wakes only idle connections, and the client-side guard keeps the
// transcript honest under any interleaving.
func TestTurnStartedSuppressedWhileBusy(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: "turn_started", TurnID: "t_gh78", Initiated: "system"})
if len(m.msgs) != 1 {
t.Errorf("turn_started while busy opened a card: len(msgs) = %d", len(m.msgs))
}
}