Skip to content

Commit ef690ed

Browse files
committed
fix: refresh model context and preserve live session state
1 parent e1e1233 commit ef690ed

9 files changed

Lines changed: 421 additions & 10 deletions

File tree

‎cmd/odek/serve.go‎

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1314,6 +1314,7 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
13141314
}
13151315
connInfo := &wsConnInfo{
13161316
ID: newWSConnID(),
1317+
Model: resolved.Model,
13171318
RemoteAddr: remote,
13181319
ConnectedAt: time.Now().UTC(),
13191320
conn: conn,
@@ -1335,7 +1336,16 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
13351336
// and the processor-loop wsSend below, so every frame of a turn is
13361337
// attributed to it.
13371338
turnTag := &wsTurnAnnotator{}
1338-
wsSend := turnTag.wrap(func(m map[string]any) { writeWSJSON(conn, m) })
1339+
wsSend := turnTag.wrap(func(m map[string]any) {
1340+
if m["type"] == "session" {
1341+
if sid, _ := m["session_id"].(string); sid != "" {
1342+
// New conversations acquire an ID inside handlePrompt.
1343+
// Bind it before tools can finish and dispatch a wake.
1344+
connInfo.setLive(sid, true)
1345+
}
1346+
}
1347+
writeWSJSON(conn, m)
1348+
})
13391349
agent, bgRT, sandboxCleanup, mcpCleanup, guardCleanup, injectionGuard, approver, err := newServeAgent(resolved, system, connInfo.ID, func(v any) error {
13401350
if m, ok := v.(map[string]any); ok {
13411351
wsSend(m)
@@ -1671,7 +1681,15 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
16711681
}
16721682
currentModel = msg.Model
16731683
resolved.Model = msg.Model
1674-
agent.SwitchModel(msg.Model)
1684+
func() {
1685+
// Model discovery may block. Keep the attached session busy
1686+
// so background completion cannot enqueue an extra wake.
1687+
live := connInfo.wireCopy()
1688+
connInfo.setLive(live.SessionID, true)
1689+
defer connInfo.setLive(live.SessionID, live.Busy)
1690+
agent.SwitchModel(msg.Model)
1691+
connInfo.setModel(msg.Model)
1692+
}()
16751693
}
16761694

16771695
if err := applyServeThinking(agent, msg.Thinking); err != nil {
@@ -1724,10 +1742,20 @@ func handleWS(store *session.Store, resources *resource.Registry, resolved confi
17241742
}
17251743
}
17261744

1727-
connInfo.setLive(msg.SessionID, true)
1745+
effectiveSessionID := msg.SessionID
1746+
if effectiveSessionID == "" && currentSession != nil {
1747+
effectiveSessionID = currentSession.ID
1748+
}
1749+
connInfo.setLive(effectiveSessionID, true)
17281750
func() {
17291751
// Panic-safe Busy pairing.
1730-
defer connInfo.setLive(msg.SessionID, false)
1752+
defer func() {
1753+
sid := effectiveSessionID
1754+
if currentSession != nil {
1755+
sid = currentSession.ID
1756+
}
1757+
connInfo.setLive(sid, false)
1758+
}()
17311759
currentSession = handlePrompt(promptCtx, wsSend, store, resources, resolved, agent, injectionGuard, currentSession, msg, &sessionInputTokens, &sessionOutputTokens, promptCancelWithApproval, &deltas, bgRT, turnTag)
17321760
}()
17331761
connInfo.recordPrompt()
Lines changed: 193 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,193 @@
1+
package main
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"net/http/httptest"
8+
"strings"
9+
"sync"
10+
"sync/atomic"
11+
"testing"
12+
"time"
13+
14+
"golang.org/x/net/websocket"
15+
)
16+
17+
func TestRESTRunReportsSessionWhileProviderIsRunning(t *testing.T) {
18+
for _, existing := range []bool{false, true} {
19+
t.Run(fmt.Sprintf("existing_%t", existing), func(t *testing.T) {
20+
entered, release := make(chan struct{}), make(chan struct{})
21+
var once sync.Once
22+
llm := mockLLM(t, func(w http.ResponseWriter, _ int) {
23+
once.Do(func() { close(entered) })
24+
<-release
25+
w.Header().Set("Content-Type", "application/json")
26+
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
27+
})
28+
defer llm.Close()
29+
env := newRestRunEnv(t, llm.URL, nil)
30+
defer close(release)
31+
body := `{"content":"hello"}`
32+
if existing {
33+
sess, err := env.store.Create(nil, "test-model", "fixture")
34+
if err != nil {
35+
t.Fatal(err)
36+
}
37+
body = fmt.Sprintf(`{"content":"hello","session_id":%q,"auth_token":%q}`, sess.ID, sess.AuthToken)
38+
}
39+
_, resp := startTestRun(t, env, body)
40+
run := lookupRun(resp["run_id"].(string))
41+
select {
42+
case <-entered:
43+
case <-time.After(5 * time.Second):
44+
t.Fatal("provider was not called")
45+
}
46+
snap := run.snapshot(true)
47+
if snap["session_id"] == "" {
48+
t.Fatal("active REST run omitted its session ID after the session event")
49+
}
50+
if runStatusTerminal(snap["status"].(string)) {
51+
t.Fatal("fixture already completed")
52+
}
53+
})
54+
}
55+
}
56+
57+
func TestWebSocketModelDiscoveryKeepsSessionBusy(t *testing.T) {
58+
var models atomic.Int32
59+
entered, release := make(chan struct{}), make(chan struct{})
60+
llm := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61+
w.Header().Set("Content-Type", "application/json")
62+
if r.Method == http.MethodGet && strings.HasSuffix(r.URL.Path, "/models") {
63+
if models.Add(1) == 1 {
64+
close(entered)
65+
<-release
66+
}
67+
fmt.Fprint(w, `{"data":[]}`)
68+
return
69+
}
70+
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
71+
}))
72+
defer llm.Close()
73+
defer setTestEnv(t, llm.URL)()
74+
t.Setenv("ODEK_MODEL", "deepseek-v4-flash")
75+
store := newTestSessionStore(t)
76+
sess, err := store.Create(nil, "test-model", "fixture")
77+
if err != nil {
78+
t.Fatal(err)
79+
}
80+
ln, mux := buildServeMux(t, store)
81+
defer startServeTest(t, ln, mux)()
82+
defer close(release)
83+
wsUpgradeLimiter.reset()
84+
conn := dialTestWS(t, ln.Addr().String())
85+
defer conn.Close()
86+
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
87+
payload, _ := json.Marshal(map[string]any{"type": "session_switch", "session_id": sess.ID, "auth_token": sess.AuthToken})
88+
if err := websocket.Message.Send(conn, string(payload)); err != nil {
89+
t.Fatal(err)
90+
}
91+
for {
92+
var raw []byte
93+
if err := websocket.Message.Receive(conn, &raw); err != nil {
94+
t.Fatal(err)
95+
}
96+
var frame map[string]any
97+
if err := json.Unmarshal(raw, &frame); err != nil {
98+
t.Fatal(err)
99+
}
100+
if frame["type"] == "session" {
101+
break
102+
}
103+
}
104+
if err := websocket.Message.Send(conn, `{"type":"prompt","content":"hello","model":"custom-small"}`); err != nil {
105+
t.Fatal(err)
106+
}
107+
select {
108+
case <-entered:
109+
case <-time.After(5 * time.Second):
110+
t.Fatal("discovery not called")
111+
}
112+
if got := (wsWakeRouter{}).State(sess.ID); got != wakeBusy {
113+
t.Fatalf("operator turn blocked in discovery but wake state=%v, want busy", got)
114+
}
115+
}
116+
117+
func TestWebSocketKeepsEffectiveSessionBoundWhileRunning(t *testing.T) {
118+
for _, existing := range []bool{false, true} {
119+
t.Run(fmt.Sprintf("existing_%t", existing), func(t *testing.T) {
120+
entered, release := make(chan struct{}), make(chan struct{})
121+
var once sync.Once
122+
llm := mockLLM(t, func(w http.ResponseWriter, _ int) {
123+
once.Do(func() { close(entered) })
124+
<-release
125+
w.Header().Set("Content-Type", "application/json")
126+
fmt.Fprint(w, `{"choices":[{"message":{"content":"ok"}}]}`)
127+
})
128+
defer llm.Close()
129+
defer setTestEnv(t, llm.URL)()
130+
t.Setenv("ODEK_MODEL", "deepseek-v4-flash")
131+
store := newTestSessionStore(t)
132+
ln, mux := buildServeMux(t, store)
133+
defer startServeTest(t, ln, mux)()
134+
defer close(release)
135+
wsUpgradeLimiter.reset()
136+
conn := dialTestWS(t, ln.Addr().String())
137+
defer conn.Close()
138+
conn.SetReadDeadline(time.Now().Add(5 * time.Second))
139+
readSession := func() string {
140+
for {
141+
var raw []byte
142+
if err := websocket.Message.Receive(conn, &raw); err != nil {
143+
t.Fatal(err)
144+
}
145+
var frame map[string]any
146+
if err := json.Unmarshal(raw, &frame); err != nil {
147+
t.Fatal(err)
148+
}
149+
if frame["type"] == "session" {
150+
return frame["session_id"].(string)
151+
}
152+
}
153+
}
154+
if existing {
155+
sess, err := store.Create(nil, "test-model", "fixture")
156+
if err != nil {
157+
t.Fatal(err)
158+
}
159+
payload, _ := json.Marshal(map[string]any{"type": "session_switch", "session_id": sess.ID, "auth_token": sess.AuthToken})
160+
if err := websocket.Message.Send(conn, string(payload)); err != nil {
161+
t.Fatal(err)
162+
}
163+
_ = readSession()
164+
}
165+
wantModel := loadJSONMockResolved().Model
166+
payload := map[string]any{"type": "prompt", "content": "hello"}
167+
if existing {
168+
wantModel = "gpt-4o"
169+
payload["model"] = wantModel
170+
}
171+
data, _ := json.Marshal(payload)
172+
if err := websocket.Message.Send(conn, string(data)); err != nil {
173+
t.Fatal(err)
174+
}
175+
sid := readSession()
176+
select {
177+
case <-entered:
178+
case <-time.After(5 * time.Second):
179+
t.Fatal("provider was not called")
180+
}
181+
conns := wsConnsForSession(sid)
182+
if len(conns) != 1 || !conns[0].isBusy() {
183+
t.Fatalf("active socket lost session binding: %d matching connections", len(conns))
184+
}
185+
if got := conns[0].wireCopy().Model; got != wantModel {
186+
t.Errorf("live model=%q, want %q", got, wantModel)
187+
}
188+
if got := (wsWakeRouter{}).State(sid); got != wakeBusy {
189+
t.Fatalf("wake state=%v, want busy", got)
190+
}
191+
})
192+
}
193+
}

‎cmd/odek/serve_runs.go‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -233,6 +233,12 @@ func (c *wsConnInfo) setLive(session string, busy bool) {
233233
c.mu.Unlock()
234234
}
235235

236+
func (c *wsConnInfo) setModel(model string) {
237+
c.mu.Lock()
238+
c.Model = model
239+
c.mu.Unlock()
240+
}
241+
236242
// isBusy reports the connection's busy flag (wake dispatcher's per-session
237243
// exclusion reads this — see cmd/odek/bg_wake.go).
238244
func (c *wsConnInfo) isBusy() bool {
@@ -490,6 +496,9 @@ func (r *serveRun) record(v any) error {
490496
r.InputTokens = numberOf(m["inputTokens"])
491497
r.OutputTokens = numberOf(m["outputTokens"])
492498
case "session":
499+
if id, _ := m["session_id"].(string); id != "" {
500+
r.SessionID = id
501+
}
493502
// The session event carries the session auth token for live WS
494503
// clients; recording it into the run's event tail would hand the
495504
// token to any instance-token holder via GET /api/runs/{id},
@@ -822,6 +831,7 @@ func startServeRun(
822831

823832
run := &serveRun{
824833
ID: newRunID(),
834+
SessionID: req.SessionID,
825835
Model: resolved.Model,
826836
Status: "running",
827837
StartedAt: time.Now().UTC(),

‎internal/bgproc/bgproc.go‎

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -781,8 +781,8 @@ func (r *outputRing) size() int64 {
781781
return r.dropped + int64(len(r.buf))
782782
}
783783

784-
// readFrom returns up to limit bytes (int: a buffer size, bounded at the
785-
// caller) of output after absolute offset since, plus the cursor for the
784+
// readFrom returns up to limit bytes, or one complete rune when it exceeds
785+
// the limit, of output after absolute offset since, plus the cursor for the
786786
// next read. The cursor is an absolute offset into the logical stream:
787787
// marker bytes are not counted.
788788
func (r *outputRing) readFrom(since int64, limit int) (string, int64) {
@@ -813,10 +813,21 @@ func (r *outputRing) readFrom(since int64, limit int) (string, int64) {
813813
window := r.buf[int(rel):]
814814
if limit > 0 && len(window) > limit {
815815
cut := limit
816-
for cut > 0 && !utf8.RuneStart(window[cut]) {
817-
cut--
816+
// Walk complete runes rather than testing continuation bytes:
817+
// invalid UTF-8 is a one-byte RuneError, so binary output advances
818+
// too. Always return at least one rune to avoid a stuck cursor.
819+
boundary := 0
820+
for boundary < cut {
821+
_, width := utf8.DecodeRune(window[boundary:])
822+
if boundary+width > cut {
823+
if boundary == 0 {
824+
boundary = width
825+
}
826+
break
827+
}
828+
boundary += width
818829
}
819-
window = window[:cut]
830+
window = window[:boundary]
820831
}
821832
return marker + string(window), start + int64(len(window))
822833
}
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
package bgproc
2+
3+
import (
4+
"strings"
5+
"testing"
6+
"unicode/utf8"
7+
)
8+
9+
func TestOutputPaginationMakesProgressWithSmallUnicodeLimits(t *testing.T) {
10+
for _, limit := range []int{1, 2, 3, 4, 5} {
11+
r := &outputRing{limit: 1024}
12+
want := "a😀é中z"
13+
_, _ = r.Write([]byte(want))
14+
var cursor int64
15+
var got strings.Builder
16+
for cursor < int64(len(want)) {
17+
chunk, next := r.readFrom(cursor, limit)
18+
if next <= cursor || chunk == "" {
19+
t.Fatalf("limit=%d stalled at cursor=%d with unread output", limit, cursor)
20+
}
21+
if !utf8.ValidString(chunk) {
22+
t.Fatalf("limit=%d returned invalid UTF-8: %q", limit, chunk)
23+
}
24+
got.WriteString(chunk)
25+
cursor = next
26+
}
27+
if got.String() != want {
28+
t.Fatalf("limit=%d output=%q, want %q", limit, got.String(), want)
29+
}
30+
}
31+
}
32+
33+
func TestOutputPaginationMakesProgressWithBinaryOutput(t *testing.T) {
34+
r := &outputRing{limit: 1024}
35+
want := string([]byte{0x80, 0x81, 0x82, 0xff, 'a'})
36+
_, _ = r.Write([]byte(want))
37+
var cursor int64
38+
var got strings.Builder
39+
for cursor < int64(len(want)) {
40+
chunk, next := r.readFrom(cursor, 1)
41+
if next <= cursor {
42+
t.Fatalf("binary pagination stalled at %d", cursor)
43+
}
44+
got.WriteString(chunk)
45+
cursor = next
46+
}
47+
if got.String() != want {
48+
t.Fatalf("output=%q, want %q", got.String(), want)
49+
}
50+
}

0 commit comments

Comments
 (0)