diff --git a/AGENTS.md b/AGENTS.md index 9c14148..0b8a9a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/README.md b/README.md index 023e79d..c0e7176 100644 --- a/README.md +++ b/README.md @@ -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. --- diff --git a/internal/client/client.go b/internal/client/client.go index daf7606..38dc1f4 100644 --- a/internal/client/client.go +++ b/internal/client/client.go @@ -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* diff --git a/internal/client/turn_started_decode_test.go b/internal/client/turn_started_decode_test.go new file mode 100644 index 0000000..8b2b7d8 --- /dev/null +++ b/internal/client/turn_started_decode_test.go @@ -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) + } +} diff --git a/internal/tui/events.go b/internal/tui/events.go index 5e96319..064120a 100644 --- a/internal/tui/events.go +++ b/internal/tui/events.go @@ -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 diff --git a/internal/tui/turn_started_test.go b/internal/tui/turn_started_test.go new file mode 100644 index 0000000..ae58d30 --- /dev/null +++ b/internal/tui/turn_started_test.go @@ -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)) + } +}