Skip to content

Commit a8cf322

Browse files
feat(tui): keep sockets alive through odek's 300s LLM budgets (#77)
odek v2.1.0 raised request/idle timeouts to 300s and sends keepalive every 20s. Match the heartbeat, ignore keepalive without draining the queue, and stop hardcoding a 2-minute stall in the failure card. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent aa7f346 commit a8cf322

8 files changed

Lines changed: 96 additions & 16 deletions

File tree

‎README.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,11 @@ own front-end settings are separate; see [Configuration](#configuration).
287287
- **Cost tracking** — when odek has token prices configured, the header shows
288288
the running session spend, each turn footer its estimated cost, and
289289
`/stats` adds the `max_cost_usd` cap when set; hidden entirely otherwise.
290-
- **Live server snapshot** — a 25s heartbeat measures WebSocket round-trip
290+
- **Live server snapshot** — a 20s heartbeat measures WebSocket round-trip
291291
latency and refreshes server uptime, connection count, and streaming state
292292
(the ⚡ badge beside the model); `/server` opens the full cockpit card.
293+
odek ≥ v2.1.0 also pushes `keepalive` so a thinking turn silent for
294+
minutes does not lose the socket to an idle proxy.
293295

294296
### Accessibility & plain mode
295297

‎docs/INTEGRATIONS.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,11 +60,12 @@ These workflows have no server endpoint; bodek cannot offer them:
6060
|---------|-----------|--------|----|
6161
| `prompt` (content/model/thinking/attachments) | → | `SendPrompt` | composer ⏎ |
6262
| `approval_response` | → | `SendApproval` | approval queue (`A`/`D`/`T`, friction typing) |
63-
| `ping` | → | `Ping` | 25s heartbeat |
63+
| `ping` | → | `Ping` | 20s heartbeat |
6464
| `cancel` | → | `SendCancel` | `esc` (REST fallback) |
6565
| `session_switch` | → | `SessionSwitch` | session resume, reconnect re-adopt |
6666
| `skill_prompt_response` | → | `SendSkillPromptResponse` | suggestion card (`alt+s`/`alt+x`) |
6767
| `server_info` / `pong` | ← | — | cockpit, stream badge, RTT |
68+
| `keepalive` | ← | — | ignored (odek ≥ v2.1.0 idle traffic; must not drain the queue) |
6869
| `session` | ← | — | session/token capture, model sync |
6970
| `token` / `token_delta` | ← | — | transcript answer (coalesced) |
7071
| `thinking` / `thinking_delta` | ← | — | reasoning accordions |

‎internal/client/client_test.go‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,15 @@ func TestDecodeEvents(t *testing.T) {
134134
}
135135
},
136136
},
137+
{
138+
name: "keepalive",
139+
frame: `{"type":"keepalive","t":1755768000000}`,
140+
check: func(t *testing.T, e Event) {
141+
if e.Type != "keepalive" || e.T == 0 {
142+
t.Fatalf("bad keepalive decode: %+v", e)
143+
}
144+
},
145+
},
137146
{
138147
name: "memory_event",
139148
frame: `{"type":"memory_event","event":"merge","target":"user","count":3}`,

‎internal/tui/errclass.go‎

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import "strings"
44

55
// ── provider-failure classification ─────────────────────────────────────────
66
//
7-
// Turn failures arrive as raw provider-client text ("iteration 17: llm:
8-
// stream idle for over 2m0s without an event"). Under parallel-agent load
9-
// the dominant failure is a stalled stream — the provider accepted the
10-
// prompt and then went silent until the watchdog aborted — not an HTTP
11-
// error; raw 429s are absorbed by the client's retry budget and only
12-
// surface when it is exhausted. The classifier sorts the wire strings into
13-
// the handful of classes a user can act on, so the turn card explains what
14-
// happened and how to recover instead of echoing an LLM-internal line.
7+
// Turn failures arrive as raw provider-client text ("llm: stream idle
8+
// timeout", or the older "iteration 17: llm: stream idle for over 5m0s
9+
// without an event"). Under parallel-agent load the dominant failure is
10+
// a stalled stream — the provider accepted the prompt and then went
11+
// silent until the watchdog aborted — not an HTTP error; raw 429s are
12+
// absorbed by the client's retry budget and only surface when it is
13+
// exhausted. The classifier sorts the wire strings into the handful of
14+
// classes a user can act on, so the turn card explains what happened
15+
// and how to recover instead of echoing an LLM-internal line. odek
16+
// ≥ v2.1.0 defaults the idle/request budgets to 300s.
1517

1618
type errClass uint8
1719

@@ -55,13 +57,13 @@ func (m *Model) errorCard(raw string) string {
5557
}
5658
switch classifyErr(raw) {
5759
case errStall:
58-
return "**⚠ Provider stream stalled.** The provider accepted the prompt, then sent nothing for over 2m — usually throttling under parallel agent load" + hint + "."
60+
return "**⚠ Provider stream stalled.** The provider accepted the prompt, then went silent past the idle timeout — raise `llm.stream_idle_timeout_seconds` (odek default 300s) if the model is still thinking" + hint + "."
5961
case errRateLimit:
6062
return "**⚠ Provider rate-limited (HTTP 429).** Concurrent agents exhausted the provider's stream budget" + hint + "."
6163
case errConnDrop:
6264
return "**⚠ Provider connection dropped** mid-stream" + hint + "."
6365
case errTimeout:
64-
return "**⚠ Provider request timed out.**" + hint
66+
return "**⚠ Provider request timed out.** Raise `llm.request_timeout_seconds` (odek default 300s) if the model is still thinking" + hint
6567
default:
6668
return "**Error:** " + sanitize(raw)
6769
}

‎internal/tui/errorcard_test.go‎

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ func TestClassifyErr(t *testing.T) {
2525
}{
2626
{"iteration 17: llm: stream idle for over 2m0s without an event", errStall},
2727
{"llm: stream idle for over 2m0s without an event", errStall},
28+
{"llm: stream idle timeout", errStall},
29+
{"iteration 4: llm: stream idle for over 5m0s without an event", errStall},
2830
{"llm: provider rate limit (status 429) after 8 attempts", errRateLimit},
2931
{"iteration 3: llm: read stream: read tcp 1.2.3.4:443->5.6.7.8:55: read: connection reset by peer", errConnDrop},
3032
{"context deadline exceeded", errTimeout},
@@ -154,3 +156,26 @@ func TestFooterRetryHintOnError(t *testing.T) {
154156
t.Errorf("retry hint must hide while a draft exists: %q", foot)
155157
}
156158
}
159+
160+
// TestErrorCardStallDoesNotHardcodeTwoMinutes pins the odek v2.1.0 budgets:
161+
// the stall card must not claim a 2-minute silence, and the timeout card
162+
// must point at request_timeout_seconds (default 300s).
163+
func TestErrorCardStallDoesNotHardcodeTwoMinutes(t *testing.T) {
164+
m := errorTurn(t, "llm: stream idle timeout")
165+
card := m.msgs[len(m.msgs)-1].content
166+
if strings.Contains(card, "2m") {
167+
t.Errorf("stall card still hardcodes the old 120s budget: %q", card)
168+
}
169+
if !strings.Contains(card, "idle timeout") {
170+
t.Errorf("stall card missing idle-timeout wording: %q", card)
171+
}
172+
if !strings.Contains(card, "stream_idle_timeout_seconds") {
173+
t.Errorf("stall card missing the config knob: %q", card)
174+
}
175+
176+
to := errorTurn(t, "context deadline exceeded")
177+
got := to.msgs[len(to.msgs)-1].content
178+
if !strings.Contains(got, "request_timeout_seconds") {
179+
t.Errorf("timeout card missing the config knob: %q", got)
180+
}
181+
}

‎internal/tui/events.go‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,13 @@ func (m *Model) handleEvent(ev client.Event) (tea.Model, tea.Cmd) {
238238
}
239239
stream = true
240240

241+
case "keepalive":
242+
// odek ≥ v2.1.0 pushes idle traffic every 20s so proxies do not
243+
// drop a socket waiting on a thinking model. Not a ping reply —
244+
// ignore without refresh so the cadence cannot drain the queue
245+
// or flicker the transcript.
246+
return m, listen(m.events)
247+
241248
case "server_info", "pong":
242249
// The connect hello and every heartbeat reply carry the same server
243250
// snapshot. Only the pong closes a round trip.

‎internal/tui/model.go‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,12 @@ func (m *Model) Init() tea.Cmd {
397397
m.armHeartbeat())
398398
}
399399

400-
// pingEvery is the application-level heartbeat cadence (spec: 25s). The
401-
// server answers inline — even mid-run — so the ping doubles as a liveness
402-
// probe and the pong refreshes the server snapshot (RTT, uptime, conns).
403-
const pingEvery = 25 * time.Second
400+
// pingEvery is the application-level heartbeat cadence (20s). Matches the
401+
// odek ≥ v2.1.0 server keepalive and stays under typical 30–60s proxy idle
402+
// timeouts so a thinking turn that is silent for minutes does not drop the
403+
// socket. The server answers inline — even mid-run — so the ping doubles
404+
// as a liveness probe and the pong refreshes the server snapshot.
405+
const pingEvery = 20 * time.Second
404406

405407
// heartbeatMsg re-arms the heartbeat loop.
406408
type heartbeatMsg struct{}

‎internal/tui/spec_v2_test.go‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,38 @@ func TestServerInfoAndPong(t *testing.T) {
9595
}
9696
}
9797

98+
func TestKeepaliveIsSilent(t *testing.T) {
99+
m := newTestModel()
100+
m.busy = true
101+
m.status = "thinking"
102+
m.queue = []string{"queued follow-up"}
103+
n := len(m.msgs)
104+
notices := len(m.notices)
105+
106+
_, cmd := m.handleEvent(client.Event{Type: "keepalive", T: time.Now().UnixMilli()})
107+
if cmd == nil {
108+
t.Error("keepalive must keep the event listener armed")
109+
}
110+
if len(m.queue) != 1 {
111+
t.Fatal("keepalive must not drain the prompt queue")
112+
}
113+
if !m.busy || m.status != "thinking" {
114+
t.Errorf("keepalive mutated turn state: busy=%v status=%q", m.busy, m.status)
115+
}
116+
if len(m.msgs) != n {
117+
t.Error("keepalive must not append transcript")
118+
}
119+
if len(m.notices) != notices {
120+
t.Errorf("keepalive posted a notice: %v", m.notices)
121+
}
122+
}
123+
124+
func TestPingEveryMatchesOdekKeepalive(t *testing.T) {
125+
if pingEvery != 20*time.Second {
126+
t.Errorf("pingEvery = %v, want 20s (under proxy idle; matches odek serve keepalive)", pingEvery)
127+
}
128+
}
129+
98130
func TestCancelledTurnClosesCleanly(t *testing.T) {
99131
m := newTestModel()
100132
streamingTurn(m)

0 commit comments

Comments
 (0)