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
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
75 changes: 75 additions & 0 deletions internal/client/foreign_session_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
45 changes: 38 additions & 7 deletions internal/client/rest.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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).
Expand Down
16 changes: 15 additions & 1 deletion internal/tui/panels.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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}
Expand Down
30 changes: 30 additions & 0 deletions internal/tui/superseded_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 := "<untrusted_content_ab12 source=\"attachment:notes.txt\">\nATTACHMENT-BODY\n</untrusted_content_ab12>\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)
}
}
Loading