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
3 changes: 2 additions & 1 deletion docs/INTEGRATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
67 changes: 61 additions & 6 deletions internal/client/manage.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

Expand All @@ -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"`
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
158 changes: 158 additions & 0 deletions internal/client/promote_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
22 changes: 20 additions & 2 deletions internal/tui/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}}},
}},
})
}))
Expand All @@ -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)
Expand Down
Loading
Loading