From 0dc15250d9d56c66f0f7498d7fe57b753ec85046 Mon Sep 17 00:00:00 2001 From: Rolando Santamaria Maso Date: Sat, 10 Oct 2026 09:53:24 +0200 Subject: [PATCH] feat(tui): odek v2.34.0 episode promotion contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit POST /api/memory/episodes/promote now requires summary_sha256 and returns the promoted summary and taint sources. Bodek sends the hash, decodes the 200 body, and distinguishes 409 (stored text changed — reload and re-review) from other failures via a typed error. Promotion is the security-relevant direction, so p arms a y confirm gate naming the taint sources, summary length and hash prefix; hash-less rows (unreadable episode text) cannot promote. Adds the x → y discard flow and a detail view showing the full summary, sources and hash. Requires odek v2.34.0 or later. --- docs/INTEGRATIONS.md | 3 +- internal/client/manage.go | 67 +++++++++++-- internal/client/promote_test.go | 158 ++++++++++++++++++++++++++++++ internal/tui/integration_test.go | 22 ++++- internal/tui/mgmt.go | 89 ++++++++++++++++- internal/tui/model.go | 25 +++-- internal/tui/panels.go | 37 ++++++- internal/tui/promote_gate_test.go | 146 +++++++++++++++++++++++++++ internal/tui/view.go | 14 +++ 9 files changed, 537 insertions(+), 24 deletions(-) create mode 100644 internal/client/promote_test.go create mode 100644 internal/tui/promote_gate_test.go diff --git a/docs/INTEGRATIONS.md b/docs/INTEGRATIONS.md index 519e193..91ba5f9 100644 --- a/docs/INTEGRATIONS.md +++ b/docs/INTEGRATIONS.md @@ -34,7 +34,8 @@ update it when either side changes. | `GET /api/memory` | `Memory` | memory tab | | `POST /api/memory/facts` | `AddMemoryFact` | memory tab (`a` user, `A` env) | | `DELETE /api/memory/facts` | `DeleteMemoryFact` | memory tab (`d` → `y` confirm gate) | -| `POST /api/memory/episodes/promote` | `PromoteEpisode` | memory tab (`p`) | +| `POST /api/memory/episodes/promote` | `PromoteEpisode` | memory tab (`p` → `y` confirm gate naming taint sources, summary length and hash prefix; odek ≥ v2.34.0: body carries `session_id` + required `summary_sha256`, 200 returns `{session_id, summary, sources}`, 409 reloads for re-review, hash-less rows are un-promotable) | +| `POST /api/memory/episodes/discard` | `DiscardEpisode` | memory tab (`x` → `y` confirm gate; odek ≥ v2.34.0) | | `POST /api/memory/consolidate` | `ConsolidateMemory` | memory tab (`c` user, `E` env) | | `GET /api/skills` | `Skills` | skills tab (provenance badges) | | `POST /api/skills/promote` | `PromoteSkill` | skills tab (`p`, `P` force) | diff --git a/internal/client/manage.go b/internal/client/manage.go index bc1b8be..92d5644 100644 --- a/internal/client/manage.go +++ b/internal/client/manage.go @@ -4,8 +4,10 @@ import ( "bytes" "encoding/json" "fmt" + "io" "net/http" "net/url" + "strings" "time" ) @@ -15,8 +17,8 @@ import ( // the pending-review episode queue (tainted episodes never auto-replay). type MemoryView struct { Facts map[string][]string `json:"facts"` - // Episodes.Pending entries carry the stored episode; the promote action - // only needs the session id. + // Episodes.Pending entries carry the full stored episode text plus the + // raw-text hash the v2.34.0 promote contract requires. Episodes struct { Total int `json:"total"` Pending []PendingEpisode `json:"pending"` @@ -25,8 +27,33 @@ type MemoryView struct { // PendingEpisode is one tainted episode awaiting human promotion. type PendingEpisode struct { - SessionID string `json:"session_id"` - Summary string `json:"summary"` + SessionID string `json:"session_id"` + Summary string `json:"summary"` + SummarySHA256 string `json:"summary_sha256"` // omitted when the episode file could not be read + Turns int `json:"turns"` + CreatedAt string `json:"created_at"` + Provenance struct { + Sources []string `json:"sources"` + } `json:"provenance"` +} + +// PromoteResult is the 200 body of a successful episode promotion. +type PromoteResult struct { + SessionID string `json:"session_id"` + Summary string `json:"summary"` + Sources []string `json:"sources"` +} + +// PromoteError is a failed promotion carrying the HTTP status and the +// server's response text, so callers can tell a 409 (the stored text +// changed since it was listed) from other 4xx/5xx failures. +type PromoteError struct { + Status int + Message string +} + +func (e *PromoteError) Error() string { + return fmt.Sprintf("promote: status %d: %s", e.Status, e.Message) } // Memory fetches the memory view. @@ -68,8 +95,36 @@ func (c *Client) DeleteMemoryFact(target, oldText string) error { } // PromoteEpisode promotes a tainted episode to recallable (the human gate). -func (c *Client) PromoteEpisode(sessionID string) error { - return c.postAction("/api/memory/episodes/promote", map[string]string{"session_id": sessionID}) +// The v2.34.0 contract requires the raw-text hash; an empty hash refuses +// locally without a request. 409 means the stored text changed since it was +// listed; other failures surface as *PromoteError. +func (c *Client) PromoteEpisode(sessionID, summarySHA string) (PromoteResult, error) { + if summarySHA == "" { + return PromoteResult{}, fmt.Errorf("promote: summary_sha256 required (episode text unreadable)") + } + payload, err := json.Marshal(map[string]string{"session_id": sessionID, "summary_sha256": summarySHA}) + if err != nil { + return PromoteResult{}, err + } + resp, err := c.postJSON(c.baseURL+"/api/memory/episodes/promote", "", payload) + if err != nil { + return PromoteResult{}, err + } + defer func() { _ = resp.Body.Close() }() + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if resp.StatusCode != http.StatusOK { + return PromoteResult{}, &PromoteError{Status: resp.StatusCode, Message: strings.TrimSpace(string(body))} + } + var out PromoteResult + if err := json.Unmarshal(body, &out); err != nil { + return PromoteResult{}, fmt.Errorf("promote: decode response: %w", err) + } + return out, nil +} + +// DiscardEpisode drops a tainted episode without promoting it (v2.34.0). +func (c *Client) DiscardEpisode(sessionID string) error { + return c.postAction("/api/memory/episodes/discard", map[string]string{"session_id": sessionID}) } // ConsolidateMemory merges similar facts through the LLM. diff --git a/internal/client/promote_test.go b/internal/client/promote_test.go new file mode 100644 index 0000000..5b8a43a --- /dev/null +++ b/internal/client/promote_test.go @@ -0,0 +1,158 @@ +package client + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +// TestPromoteEpisodeContract pins the odek v2.34.0 promote contract: the +// body carries session_id and the required summary_sha256, a 200 body is +// decoded into PromoteResult, and failures map to typed errors that carry +// the HTTP status and the server's message. +func TestPromoteEpisodeContract(t *testing.T) { + var gotBody map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(b, &gotBody) + switch gotBody["summary_sha256"] { + case "deadbeef01": + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "session_id": "s1", "summary": "fixed the login bug", "sources": []string{"browser"}, + }) + case "stalehash": + w.WriteHeader(http.StatusConflict) + w.Write([]byte("summary changed since listing")) + case "rejecthash": + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("unknown episode")) + default: + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("boom")) + } + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + + res, err := c.PromoteEpisode("s1", "deadbeef01") + if err != nil { + t.Fatalf("promote: %v", err) + } + if res.SessionID != "s1" || res.Summary != "fixed the login bug" || len(res.Sources) != 1 || res.Sources[0] != "browser" { + t.Errorf("result = %+v", res) + } + if gotBody["session_id"] != "s1" || gotBody["summary_sha256"] != "deadbeef01" { + t.Errorf("request body = %v", gotBody) + } + + // Empty hash refuses locally — the server must never see a request. + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Error("empty hash still reached the server") + }) + c2 := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + if _, err := c2.PromoteEpisode("s1", ""); err == nil { + t.Fatal("empty hash accepted") + } + + // 409, 400 and 500 are distinct typed errors carrying the message. + for _, tc := range []struct { + hash string + status int + want string + }{ + {"stalehash", http.StatusConflict, "summary changed since listing"}, + {"rejecthash", http.StatusBadRequest, "unknown episode"}, + {"boomhash", http.StatusInternalServerError, "boom"}, + } { + c3 := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + srv.Config.Handler = http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + b, _ := io.ReadAll(r.Body) + var m map[string]any + _ = json.Unmarshal(b, &m) + switch m["summary_sha256"] { + case "stalehash": + w.WriteHeader(http.StatusConflict) + w.Write([]byte("summary changed since listing")) + case "rejecthash": + w.WriteHeader(http.StatusBadRequest) + w.Write([]byte("unknown episode")) + default: + w.WriteHeader(http.StatusInternalServerError) + w.Write([]byte("boom")) + } + }) + _, err := c3.PromoteEpisode("s1", tc.hash) + if err == nil { + t.Fatalf("%s: want error", tc.hash) + } + pe, ok := err.(*PromoteError) + if !ok { + t.Fatalf("%s: err %T not *PromoteError: %v", tc.hash, err, err) + } + if pe.Status != tc.status { + t.Errorf("%s: status = %d, want %d", tc.hash, pe.Status, tc.status) + } + if !strings.Contains(pe.Message, tc.want) { + t.Errorf("%s: message = %q, want containing %q", tc.hash, pe.Message, tc.want) + } + } +} + +// TestMemoryViewPendingFields pins the v2.34.0 /api/memory pending-episode +// fields: full summary, hash, turns, created_at, provenance sources. +func TestMemoryViewPendingFields(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "facts": map[string][]string{"user": {"prefers vim"}}, + "episodes": map[string]any{"total": 1, "pending": []map[string]any{{ + "session_id": "s1", + "summary": "full stored episode text, not truncated", + "summary_sha256": "abc123", + "turns": 7, + "created_at": "2026-10-10T08:00:00Z", + "provenance": map[string]any{"sources": []string{"browser", "mcp:fs:read"}}, + }}}, + }) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + v, err := c.Memory() + if err != nil { + t.Fatalf("memory: %v", err) + } + if len(v.Episodes.Pending) != 1 { + t.Fatalf("pending = %d", len(v.Episodes.Pending)) + } + e := v.Episodes.Pending[0] + if e.SummarySHA256 != "abc123" || e.Turns != 7 || e.CreatedAt != "2026-10-10T08:00:00Z" { + t.Errorf("pending episode = %+v", e) + } + if len(e.Provenance.Sources) != 2 || e.Provenance.Sources[1] != "mcp:fs:read" { + t.Errorf("sources = %v", e.Provenance.Sources) + } +} + +// TestDiscardEpisode pins POST /api/memory/episodes/discard {session_id}. +func TestDiscardEpisode(t *testing.T) { + var got map[string]any + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !strings.HasSuffix(r.URL.Path, "/api/memory/episodes/discard") { + t.Errorf("path = %s", r.URL.Path) + } + json.NewDecoder(r.Body).Decode(&got) + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + c := &Client{baseURL: srv.URL, http: &http.Client{Timeout: time.Second}} + if err := c.DiscardEpisode("s9"); err != nil { + t.Fatalf("discard: %v", err) + } + if got["session_id"] != "s9" { + t.Errorf("body = %v", got) + } +} diff --git a/internal/tui/integration_test.go b/internal/tui/integration_test.go index 05fda25..55c0647 100644 --- a/internal/tui/integration_test.go +++ b/internal/tui/integration_test.go @@ -29,6 +29,9 @@ type standInObs struct { factDeletes int lastEventRunID string prompts int // POST /api/prompt (headless runs) + promotes int // POST /api/memory/episodes/promote + promoteStatus int // when non-zero, the promote reply status (0 = 200 OK) + discards int // POST /api/memory/episodes/discard } var standInSaw standInObs @@ -179,7 +182,9 @@ func standIn(t *testing.T, token string) *Model { json.NewEncoder(w).Encode(map[string]any{ "facts": map[string][]string{"user": {"prefers vim"}, "env": {"go 1.25"}}, "episodes": map[string]any{"total": 2, "pending": []map[string]any{ - {"session_id": "s1", "summary": "fixed the login bug"}, + {"session_id": "s1", "summary": "fixed the login bug", "summary_sha256": "deadbeef01", + "turns": 3, "created_at": "2026-10-10T08:00:00Z", + "provenance": map[string]any{"sources": []string{"browser"}}}, }}, }) })) @@ -190,7 +195,20 @@ func standIn(t *testing.T, token string) *Model { w.WriteHeader(http.StatusOK) })) mux.HandleFunc("/api/memory/episodes/promote", guard(func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) + standInSaw.promotes++ + if standInSaw.promoteStatus != 0 { + w.WriteHeader(standInSaw.promoteStatus) + w.Write([]byte("summary changed since listing")) + return + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]any{ + "session_id": "s1", "summary": "fixed the login bug", "sources": []string{"browser"}, + }) + })) + mux.HandleFunc("/api/memory/episodes/discard", guard(func(w http.ResponseWriter, r *http.Request) { + standInSaw.discards++ + w.WriteHeader(http.StatusNoContent) })) mux.HandleFunc("/api/memory/consolidate", guard(func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) diff --git a/internal/tui/mgmt.go b/internal/tui/mgmt.go index 04bc30e..34b439c 100644 --- a/internal/tui/mgmt.go +++ b/internal/tui/mgmt.go @@ -2,7 +2,9 @@ package tui import ( "encoding/json" + "errors" "fmt" + "net/http" "sort" "strings" @@ -36,9 +38,11 @@ type mgmtMsg struct { // memRow is one selectable memory row. type memRow struct { - kind string // "user" | "env" | "episode" - text string - sessionID string // episodes: promote target + kind string // "user" | "env" | "episode" + text string + sessionID string // episodes: promote target + summarySHA256 string // episodes: raw-text hash ("" = unreadable, cannot promote) + sources []string // episodes: taint sources } // toolRow is one selectable tools/config row. @@ -205,6 +209,7 @@ func (m *Model) handleMgmtMsg(msg mgmtMsg) { } m.memView = msg.mem m.memRows = buildMemRows(msg.mem) + m.pendingMemReload = false // a fresh listing supersedes any stale-row failure m.panelSel = anchorRow(m.panelSel, m.memRows, sel) if len(m.memRows) == 0 { m.panelMsg = "no facts or pending episodes" @@ -305,7 +310,8 @@ func buildMemRows(v client.MemoryView) []memRow { } } for _, e := range v.Episodes.Pending { - rows = append(rows, memRow{kind: "episode", text: e.Summary, sessionID: e.SessionID}) + rows = append(rows, memRow{kind: "episode", text: e.Summary, sessionID: e.SessionID, + summarySHA256: e.SummarySHA256, sources: e.Provenance.Sources}) } return rows } @@ -457,13 +463,73 @@ func (m *Model) memDeleteSelected() tea.Cmd { } func (m *Model) memPromoteSelected() tea.Cmd { + r := m.memSelected() + if r == nil || r.kind != "episode" { + return nil + } + if r.summarySHA256 == "" { + m.panelMsg = "cannot promote: episode text unreadable" + m.refresh() + return nil + } + cl := m.cl + sid, hash := r.sessionID, r.summarySHA256 + return func() tea.Msg { + res, err := cl.PromoteEpisode(sid, hash) + return mgmtPromoteMsg{tab: panelMemory, res: res, err: err} + } +} + +// memDiscardSelected drops the selected tainted episode without promoting. +func (m *Model) memDiscardSelected() tea.Cmd { r := m.memSelected() if r == nil || r.kind != "episode" { return nil } cl := m.cl sid := r.sessionID - return func() tea.Msg { return mgmtActionMsg{tab: panelMemory, err: cl.PromoteEpisode(sid)} } + return func() tea.Msg { return mgmtActionMsg{tab: panelMemory, err: cl.DiscardEpisode(sid)} } +} + +// mgmtPromoteMsg reports an episode-promotion outcome. Promotion has its +// own message because the v2.34.0 failures are typed: a 409 means the +// stored text changed since it was listed, so the row must be refetched +// and re-reviewed rather than retried blindly. +type mgmtPromoteMsg struct { + tab panelMode + res client.PromoteResult + err error +} + +// afterMgmtPromote surfaces the promote outcome. 409 (or any other 4xx, +// where the row is likely stale) refetches; 5xx and network errors keep +// the row so the user can retry. +func (m *Model) afterMgmtPromote(msg mgmtPromoteMsg) tea.Cmd { + if msg.err == nil { + srcs := "none" + if len(msg.res.Sources) > 0 { + srcs = strings.Join(msg.res.Sources, ", ") + } + note := fmt.Sprintf("episode promoted (%d chars, sources: %s)", len(msg.res.Summary), srcs) + m.pendingMemReload = false + cmd := m.openMemory() + m.panelMsg = note // openMemory resets the line; the outcome outlives it + return cmd + } + var pe *client.PromoteError + if errors.As(msg.err, &pe) && pe.Status >= 400 && pe.Status < 500 { + n := "promote failed: " + pe.Message + if pe.Status == http.StatusConflict { + n = "summary changed since you reviewed it — reloaded, review again" + } + m.pendingMemReload = true + cmd := m.openMemory() + m.panelMsg = n // openMemory resets the line; the outcome outlives it + return cmd + } + m.panelMsg = "promote failed: " + msg.err.Error() + m.pendingMemReload = false + return nil } func (m *Model) memConsolidate(target string) tea.Cmd { @@ -549,6 +615,9 @@ func (m *Model) memRowsRender(w int) []string { detail := " fact · " + r.kind if r.kind == "episode" { detail = " ⏳ pending episode · " + shortID(r.sessionID) + if r.summarySHA256 == "" { + detail += " · text unreadable — cannot promote" + } } budget := w - 2 - lipgloss.Width(detail) prefix, lab := " ", th.acItem.Render(truncate(label, budget)) @@ -866,6 +935,16 @@ func (m *Model) mgmtDetailLines(w int) []string { if r.kind == "episode" { out = append(out, th.acSel.Render("› pending episode")) out = append(out, th.acDetail.Render("session "+sanitize(r.sessionID))) + meta := []string{"hash " + sanitize(shortHash(r.summarySHA256))} + if len(r.sources) > 0 { + meta = append(meta, "sources: "+sanitize(strings.Join(r.sources, ", "))) + } else { + meta = append(meta, "sources: none") + } + out = append(out, th.acDetail.Render(strings.Join(meta, " · "))) + if r.summarySHA256 == "" { + out = append(out, th.acDim.Render("cannot promote: episode text unreadable")) + } } else { out = append(out, th.acSel.Render("› "+sanitize(r.kind)+" fact")) } diff --git a/internal/tui/model.go b/internal/tui/model.go index eeb2e30..f41497e 100644 --- a/internal/tui/model.go +++ b/internal/tui/model.go @@ -349,15 +349,16 @@ type Model struct { evRunFilter string // events tab: drill-in — the ring of one run (wins over session) // Management tab state (memory / skills / tools / config). - memView client.MemoryView - memRows []memRow - memTarget string // add-fact editor target ("user" | "env") - skills []client.Skill - toolRows []toolRow - mcpJump bool // a /mcp invocation armed a one-shot focus - mcpFocus string // /mcp focus target, consumed on fetch - cfgRows []cfgRow - shutdownReq bool // shutdown sent — the socket drop is expected, not a failure + memView client.MemoryView + memRows []memRow + pendingMemReload bool // a stale-row promote failure scheduled a refetch + memTarget string // add-fact editor target ("user" | "env") + skills []client.Skill + toolRows []toolRow + mcpJump bool // a /mcp invocation armed a one-shot focus + mcpFocus string // /mcp focus target, consumed on fetch + cfgRows []cfgRow + shutdownReq bool // shutdown sent — the socket drop is expected, not a failure // Cockpit live snapshots (one-shot fetch on open). healthSnap *client.Health @@ -816,6 +817,12 @@ func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } return m, nil + case mgmtPromoteMsg: + if m.panel == msg.tab { + return m, m.afterMgmtPromote(msg) + } + return m, nil + case limitsMsg: m.handleLimitsMsg(msg) return m, nil diff --git a/internal/tui/panels.go b/internal/tui/panels.go index 06ed62c..d7db384 100644 --- a/internal/tui/panels.go +++ b/internal/tui/panels.go @@ -60,6 +60,8 @@ const ( confirmStopJob confirmQueueDelete confirmQuit + confirmEpisodePromote + confirmEpisodeDiscard ) // handleConfirmKey resolves an armed delete: y fires it against the @@ -75,6 +77,10 @@ func (m *Model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.deleteSelected() case confirmFactDelete: return m, m.memDeleteSelected() + case confirmEpisodePromote: + return m, m.memPromoteSelected() + case confirmEpisodeDiscard: + return m, m.memDiscardSelected() case confirmClear: return m, tea.Batch(m.clearConversation(), m.transientNoteCmd("conversation cleared")) case confirmCancel: @@ -134,6 +140,10 @@ func (m *Model) armConfirm(kind confirmKind, what string) tea.Cmd { verb = "cancel " case confirmStopAgent, confirmStopJob: verb = "stop " + case confirmEpisodePromote: + verb = "promote " + case confirmEpisodeDiscard: + verb = "discard " case confirmQuit: verb = "quit " } @@ -433,6 +443,9 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if r := m.memSelected(); r != nil && r.kind != "episode" { return m, m.armConfirm(confirmFactDelete, r.kind+" fact") } + if r := m.memSelected(); r != nil && r.kind == "episode" { + return m, m.armConfirm(confirmEpisodeDiscard, "pending episode") + } return m, nil } if m.panel == panelConfig { @@ -476,7 +489,21 @@ func (m *Model) handlePanelKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.refreshSelectedRunApprovals() } if m.panel == panelMemory { - return m, m.memPromoteSelected() + // Promotion turns a tainted episode into trusted context replayed + // into future sessions — the security-relevant direction, so the + // gate names what the human is vouching for. Hash-less rows + // (episode file unreadable server-side) never reach the gate. + if r := m.memSelected(); r != nil && r.kind == "episode" { + if r.summarySHA256 == "" { + m.panelMsg = "cannot promote: episode text unreadable" + m.refresh() + return m, nil + } + return m, m.armConfirm(confirmEpisodePromote, fmt.Sprintf( + "episode (%d chars · sources: %s · hash %s)", + len(r.text), strings.Join(r.sources, ", "), shortHash(r.summarySHA256))) + } + return m, nil } if m.panel == panelSkills { return m, m.skillPromote(false) @@ -1469,6 +1496,14 @@ func shortID(id string) string { return id } +// shortHash trims a hex hash to its first 12 characters. +func shortHash(h string) string { + if len(h) > 12 { + return h[:12] + } + return h +} + // ago renders a coarse relative time. func ago(t time.Time) string { if t.IsZero() { diff --git a/internal/tui/promote_gate_test.go b/internal/tui/promote_gate_test.go new file mode 100644 index 0000000..95b0c03 --- /dev/null +++ b/internal/tui/promote_gate_test.go @@ -0,0 +1,146 @@ +package tui + +import ( + "net/http" + "strings" + "testing" +) + +// TestMgmtEpisodePromoteGate verifies promotion is a two-step action: p arms +// the gate (naming taint sources, summary length and short hash) without a +// request; any other key cancels without firing; y fires with the hash. +func TestMgmtEpisodePromoteGate(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + m.panelSel = 2 // the pending episode + + _, cmd := m.Update(key("p")) + if cmd != nil { + m.Update(exec(cmd)) + } + if standInSaw.promotes != 0 { + t.Fatal("p alone fired the promote — gate missing") + } + if m.confirm != confirmEpisodePromote { + t.Fatalf("p did not arm the promote gate: %d", m.confirm) + } + gate := plain(m.View()) + // The gate names the security-relevant review facts. + for _, want := range []string{"promote", "browser", "chars", "deadbeef01"} { + if !strings.Contains(gate, want) { + t.Errorf("promote gate missing %q:\n%s", want, gate) + } + } + + // Any other key disarms without firing. + m.Update(key("esc")) + if m.confirm != confirmNone { + t.Fatal("esc did not disarm the gate") + } + + // y confirms. + m.Update(key("p")) + _, cmd = m.Update(key("y")) + if cmd != nil { + m.Update(exec(cmd)) + } + if standInSaw.promotes != 1 { + t.Errorf("y fired %d promotes, want 1", standInSaw.promotes) + } +} + +// TestMgmtEpisodeNoHashCannotPromote verifies a pending episode whose file +// could not be read (no summary_sha256) cannot promote: `p` is a no-op with +// the explanatory message. +func TestMgmtEpisodeNoHashCannotPromote(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + // Replace the episode row with a hash-less one (server omitted it). + m.memRows = append([]memRow(nil), m.memRows...) + for i := range m.memRows { + if m.memRows[i].kind == "episode" { + m.memRows[i].summarySHA256 = "" + } + } + m.panelSel = 2 + if cmd := m.memPromoteSelected(); cmd != nil { + t.Fatal("promote fired on a hash-less episode") + } + if !strings.Contains(m.panelMsg, "cannot promote") { + t.Errorf("panelMsg = %q", m.panelMsg) + } +} + +// TestMgmtEpisodePromote409 verifies a 409 conflict reloads the tab and says +// the summary changed — the user re-reviews the fresh text. +func TestMgmtEpisodePromote409(t *testing.T) { + m := wired(t) + standInSaw.promoteStatus = http.StatusConflict + defer func() { standInSaw.promoteStatus = 0 }() + m.Update(exec(m.openMemory())) + m.panelSel = 2 + r := m.memSelected() + if r == nil || r.summarySHA256 == "" { + t.Fatalf("row = %+v", r) + } + m.Update(exec(m.memPromoteSelected())) + if !strings.Contains(m.panelMsg, "changed since you reviewed") { + t.Errorf("panelMsg = %q", m.panelMsg) + } + if !m.pendingMemReload { + t.Error("409 did not schedule a memory refetch") + } +} + +// TestMgmtEpisodePromoteSuccess verifies the success note carries the +// promoted summary length and sources from the server's response. +func TestMgmtEpisodePromoteSuccess(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + m.panelSel = 2 + m.Update(exec(m.memPromoteSelected())) + if !strings.Contains(m.panelMsg, "episode promoted") || !strings.Contains(m.panelMsg, "browser") { + t.Errorf("panelMsg = %q", m.panelMsg) + } +} + +// TestMgmtEpisodeDiscard verifies the discard flow: x arms a confirm gate on +// an episode row, y fires POST /api/memory/episodes/discard. +func TestMgmtEpisodeDiscard(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + m.panelSel = 2 + _, cmd := m.Update(key("x")) + if cmd != nil { + m.Update(exec(cmd)) + } + if standInSaw.discards != 0 { + t.Fatal("x alone fired the discard — gate missing") + } + if m.confirm != confirmEpisodeDiscard { + t.Fatalf("x did not arm the discard gate: %d", m.confirm) + } + _, cmd = m.Update(key("y")) + if cmd != nil { + m.Update(exec(cmd)) + } + if standInSaw.discards != 1 { + t.Errorf("y fired %d discards, want 1", standInSaw.discards) + } +} + +// TestMgmtEpisodeDetailRendering verifies the selected pending episode's +// detail view shows the full summary, its taint sources and short hash. +func TestMgmtEpisodeDetailRendering(t *testing.T) { + m := wired(t) + m.Update(exec(m.openMemory())) + m.panelSel = 2 + m.panelDetail = true + m.refresh() + out := plain(m.View()) + for _, want := range []string{"fixed the login bug", "browser", "deadbeef01"} { + if !strings.Contains(out, want) { + t.Errorf("episode detail missing %q:\n%s", want, out) + } + } +} diff --git a/internal/tui/view.go b/internal/tui/view.go index f9058ba..5b592ce 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -2049,6 +2049,20 @@ func (m *Model) footerContent() string { th.footer.Render("any other key cancels"), ) } + if m.confirm == confirmEpisodePromote { + return m.panelFooter( + th.footerDanger.Render("promote this tainted episode into recallable context?"), + th.footerKey.Render("y")+th.footerDanger.Render(" promote"), + th.footer.Render("any other key cancels"), + ) + } + if m.confirm == confirmEpisodeDiscard { + return m.panelFooter( + th.footerDanger.Render("discard this pending episode?"), + th.footerKey.Render("y")+th.footerDanger.Render(" discard"), + th.footer.Render("any other key cancels"), + ) + } if m.panelDetail { return m.panelFooter( th.footer.Render("↑↓ scroll"),