From b01f166dd2401ac06d7523b184f953ad11d084a2 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 9 Oct 2026 07:30:34 +0200 Subject: [PATCH 1/2] fix(client): load sessions created by other front-ends A session the odek WebUI created has no token in bodek's store. odek answers that detail request with a mint-only bootstrap (session token header, {"session_id","bootstrapped":true}, no transcript), which bodek decoded as the session: resume replayed nothing, adopted an empty id, and the turn errored. SessionDetail now refetches once with the minted token and reports an error instead of returning the stub. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 7 +++ internal/client/foreign_session_test.go | 75 +++++++++++++++++++++++++ internal/client/rest.go | 45 ++++++++++++--- 3 files changed, 120 insertions(+), 7 deletions(-) create mode 100644 internal/client/foreign_session_test.go diff --git a/AGENTS.md b/AGENTS.md index febb65a..27b9f30 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -249,6 +249,13 @@ modifier routes to the composer), and `Alt+A`/`Alt+D`/`Alt+T` parks `focusIdx` plus the viewport on the latest transcript message (a surviving queued successor must not yank scrollback). The unfocused queue is a shelf chip; `^Q` unfolds the strip (`qfocus`). +- Sessions other front-ends created (the odek WebUI keeps its session + tokens in the browser) must load: `client.SessionDetail` follows odek's + mint-only bootstrap reply (`{"session_id","bootstrapped":true}` plus an + `X-Session-Token` header, no transcript) with one refetch using the + minted token, and never returns that stub as a session. Replayed user + turns show `principal_prompt` (what was typed), not `content`, which + carries attachments and @-resources inlined in untrusted wrappers. - The TUI reconnects with backoff and resumes the session after a socket drop (`reconnect.go`) — don't break that by assuming a single connection per run. diff --git a/internal/client/foreign_session_test.go b/internal/client/foreign_session_test.go new file mode 100644 index 0000000..81b2bb6 --- /dev/null +++ b/internal/client/foreign_session_test.go @@ -0,0 +1,75 @@ +package client + +import ( + "net/http" + "net/http/httptest" + "sync/atomic" + "testing" + "time" +) + +// odekBootstrap mimics odek's GET /api/sessions/{id}: an unknown session +// token gets a mint-only reply (token header, {"session_id","bootstrapped"} +// body, no transcript); the minted token gets the session. +func odekBootstrap(t *testing.T, minted string, mintedWorks bool) (*Client, *int32) { + t.Helper() + var calls int32 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&calls, 1) + if r.Header.Get("X-Session-Token") == minted && mintedWorks { + w.Header().Set("X-Session-Token", minted) + _, _ = w.Write([]byte(`{"id":"web-1","model":"m","messages":[{"role":"user","content":"hi"}]}`)) + return + } + if minted != "" { + w.Header().Set("X-Session-Token", minted) + } + _, _ = w.Write([]byte(`{"session_id":"web-1","bootstrapped":true}`)) + })) + t.Cleanup(srv.Close) + return &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}}, &calls +} + +// A session another front-end created (the WebUI) loads: the bootstrap +// reply is never mistaken for the session, and the detail is refetched once +// with the minted token. +func TestSessionDetailFollowsBootstrap(t *testing.T) { + c, calls := odekBootstrap(t, "minted", true) + sess, tok, err := c.SessionDetail("web-1", "") + if err != nil { + t.Fatal(err) + } + if sess.ID != "web-1" || len(sess.Messages) != 1 || tok != "minted" { + t.Fatalf("session = %+v token=%q, want the full transcript and the minted token", sess, tok) + } + if n := atomic.LoadInt32(calls); n != 2 { + t.Fatalf("requests = %d, want bootstrap + one refetch", n) + } +} + +// A bootstrap without a token, or one the server then refuses, is an error +// — never an empty session that would adopt an empty id. +func TestSessionDetailBootstrapFailures(t *testing.T) { + c, _ := odekBootstrap(t, "", false) + if s, _, err := c.SessionDetail("web-1", ""); err == nil || s.ID != "" { + t.Fatalf("no minted token: session=%+v err=%v, want an error", s, err) + } + c, calls := odekBootstrap(t, "minted", false) + if s, _, err := c.SessionDetail("web-1", ""); err == nil || s.ID != "" { + t.Fatalf("refused minted token: session=%+v err=%v, want an error", s, err) + } + if n := atomic.LoadInt32(calls); n != 2 { + t.Fatalf("requests = %d, want exactly one retry", n) + } +} + +// A known token loads in one request, as before. +func TestSessionDetailKnownTokenSingleRequest(t *testing.T) { + c, calls := odekBootstrap(t, "minted", true) + if _, _, err := c.SessionDetail("web-1", "minted"); err != nil { + t.Fatal(err) + } + if n := atomic.LoadInt32(calls); n != 1 { + t.Fatalf("requests = %d, want 1", n) + } +} diff --git a/internal/client/rest.go b/internal/client/rest.go index 41e878b..48c980b 100644 --- a/internal/client/rest.go +++ b/internal/client/rest.go @@ -18,6 +18,10 @@ type SessionMessage struct { ToolCallID string `json:"tool_call_id,omitempty"` ToolCalls []SessionToolCall `json:"tool_calls,omitempty"` ReasoningContent string `json:"reasoning_content,omitempty"` + // PrincipalPrompt is the prompt as the operator typed it, before odek + // inlined attachments and @-resources into Content (nil on older + // records). Replay shows this, never the expanded, wrapped Content. + PrincipalPrompt *string `json:"principal_prompt,omitempty"` // Superseded marks a draft final answer the loop replaced by re-asking // the model (odek ≥ v2.33); SupersededReason is completion_nudge | // verify_retry. @@ -174,24 +178,51 @@ func (c *Client) Health() (Health, error) { // auth token (empty is accepted for sessions that have never been tokened). It // returns the effective token from the X-Session-Token response header, falling // back to the token passed in. +// +// A session another front-end created (the odek WebUI keeps its session +// tokens in the browser) is unknown to bodek's token store. For such a +// request odek answers with a mint-only bootstrap — the session token in the +// X-Session-Token header and {"session_id", "bootstrapped": true} as the +// body, never the transcript — so the detail is fetched once more with the +// minted token. func (c *Client) SessionDetail(id, token string) (Session, string, error) { - var s Session + s, eff, boot, err := c.sessionDetailOnce(id, token) + if err != nil || !boot { + return s, eff, err + } + if eff == "" || eff == token { + return Session{}, "", fmt.Errorf("session: server issued no session token") + } + s, eff, boot, err = c.sessionDetailOnce(id, eff) + if err == nil && boot { + return Session{}, "", fmt.Errorf("session: minted token was not accepted") + } + return s, eff, err +} + +// sessionDetailOnce issues one detail request; boot reports a mint-only +// bootstrap reply (token header, no transcript). +func (c *Client) sessionDetailOnce(id, token string) (s Session, eff string, boot bool, err error) { resp, err := c.doWith(c.slowHTTP, http.MethodGet, c.baseURL+"/api/sessions/"+url.PathEscape(id), token) if err != nil { - return s, "", err + return s, "", false, err } defer func() { _ = resp.Body.Close() }() if resp.StatusCode != http.StatusOK { - return s, "", fmt.Errorf("session: status %s", resp.Status) + return s, "", false, fmt.Errorf("session: status %s", resp.Status) + } + var body struct { + Session + Bootstrapped bool `json:"bootstrapped"` } - if err := json.NewDecoder(resp.Body).Decode(&s); err != nil { - return s, "", err + if err := json.NewDecoder(resp.Body).Decode(&body); err != nil { + return s, "", false, err } - eff := resp.Header.Get("X-Session-Token") + eff = resp.Header.Get("X-Session-Token") if eff == "" { eff = token } - return s, eff, nil + return body.Session, eff, body.Bootstrapped, nil } // DeleteSession removes a saved session (requires its auth token). From 34a241d319913f2e21a30660893468cf27683b10 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Fri, 9 Oct 2026 07:30:34 +0200 Subject: [PATCH 2/2] fix(tui): replay user turns as typed, not with inlined attachments odek stores a prompt with attachments and @-resources expanded into content, wrapped in untrusted markers, and the typed text in principal_prompt. Replay rendered content, so resumed sessions showed raw wrappers and file bodies in the user's turn. Co-Authored-By: Claude Opus 5.5 --- internal/tui/panels.go | 16 +++++++++++++++- internal/tui/superseded_test.go | 30 ++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 578fcda..06ed62c 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -1115,6 +1115,20 @@ func (m *Model) handleSessionSwitch(msg sessionSwitchMsg) tea.Cmd { // interleaved in arrival order, mirroring live event ingestion. System // messages are dropped; blank assistant messages (no reply, reasoning, or // steps) are skipped. +// replayPrompt is the user turn as the operator typed it. odek stores the +// prompt with attachments and @-resources inlined (wrapped in untrusted +// markers) in Content and the typed text in PrincipalPrompt; older records +// carry only Content. +func replayPrompt(mm client.SessionMessage) string { + if mm.PrincipalPrompt == nil { + return mm.Content + } + if strings.TrimSpace(*mm.PrincipalPrompt) == "" && strings.TrimSpace(mm.Content) != "" { + return "(attached files)" // an attachment-only prompt typed no text + } + return *mm.PrincipalPrompt +} + func (m *Model) replayTranscript(msgs []client.SessionMessage) { var cur *message // current turn's assistant message, not yet flushed stepByCallID := map[string]int{} @@ -1139,7 +1153,7 @@ func (m *Model) replayTranscript(msgs []client.SessionMessage) { switch mm.Role { case "user": flush() - m.msgs = append(m.msgs, message{role: roleUser, content: sanitize(mm.Content)}) + m.msgs = append(m.msgs, message{role: roleUser, content: sanitize(replayPrompt(mm))}) case "assistant": if cur == nil { cur = &message{role: roleAsst} diff --git a/internal/tui/superseded_test.go b/internal/tui/superseded_test.go index 7903481..5b90206 100644 --- a/internal/tui/superseded_test.go +++ b/internal/tui/superseded_test.go @@ -296,3 +296,33 @@ func TestDraftSelectedHintAndCopySpan(t *testing.T) { t.Fatalf("copy span = %+v, want the open draft", span) } } + +// Replay shows the prompt as typed: odek stores attachments and +// @-resources inlined (wrapped in untrusted markers) in content and the +// typed text in principal_prompt. +func TestReplayPrincipalPrompt(t *testing.T) { + typed, empty := "summarize notes.txt", "" + expanded := "\nATTACHMENT-BODY\n\n\nsummarize notes.txt" + cases := []struct { + name string + mm client.SessionMessage + want string + }{ + {"typed", client.SessionMessage{Role: "user", Content: expanded, PrincipalPrompt: &typed}, typed}, + {"attachment only", client.SessionMessage{Role: "user", Content: expanded, PrincipalPrompt: &empty}, "(attached files)"}, + {"legacy record", client.SessionMessage{Role: "user", Content: "plain"}, "plain"}, + } + for _, tc := range cases { + if got := replayPrompt(tc.mm); got != tc.want { + t.Errorf("%s: replayPrompt = %q, want %q", tc.name, got, tc.want) + } + } + m := newTestModel() + m.resize(100, 30) + m.replayTranscript([]client.SessionMessage{cases[0].mm, {Role: "assistant", Content: "done"}}) + m.refresh() + view := plain(m.View()) + if !strings.Contains(view, typed) || strings.Contains(view, "ATTACHMENT-BODY") || strings.Contains(view, "untrusted_content") { + t.Fatalf("replayed prompt leaks the expanded content:\n%s", view) + } +}