Skip to content
Open
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
44 changes: 29 additions & 15 deletions server/internal/domain/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -286,23 +286,37 @@ type Session struct {
// bumps Version. The sessions table is never modified, so the overlay only
// changes how Session Search renders an already-matched row.
type SessionEdit struct {
ID string `json:"id"` // == sessions.id
AppID string `json:"appId,omitempty"`
SessionID string `json:"session_id,omitempty"`
Seq int `json:"seq"`
AgentID string `json:"agent_id,omitempty"`
OriginalContent string `json:"original_content"`
EditedContent string `json:"edited_content"`
EditedTags []string `json:"edited_tags,omitempty"`
ID string `json:"id"` // == sessions.id
AppID string `json:"appId,omitempty"`
SessionID string `json:"session_id,omitempty"`
Seq int `json:"seq"`
AgentID string `json:"agent_id,omitempty"`
OriginalContent string `json:"original_content"`
EditedContent string `json:"edited_content,omitempty"`
// EditedContentSet mirrors whether the edited_content column is non-NULL.
// A row can exist with only a correctness mark and no content override
// (mark-only row); the overlay replaces rendered content only when this
// is true.
EditedContentSet bool `json:"-"`
EditedTags []string `json:"edited_tags,omitempty"`
// EditedTagsSet distinguishes "tags omitted (leave display tags as-is)"
// from "tags explicitly set (including [] to clear)". It mirrors whether
// the edited_tags column is non-NULL; only when true does the overlay
// replace a row's rendered tags.
EditedTagsSet bool `json:"-"`
EditedBy string `json:"edited_by,omitempty"`
Reason string `json:"reason,omitempty"`
Version int `json:"version"`
State MemoryState `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
EditedTagsSet bool `json:"-"`
// Correctness is a human review mark: "correct" or "incorrect"; empty
// means unmarked. A content edit auto-sets it to "correct".
Correctness string `json:"correctness,omitempty"`
EditedBy string `json:"edited_by,omitempty"`
Reason string `json:"reason,omitempty"`
Version int `json:"version"`
State MemoryState `json:"state"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}

// SessionEdit correctness mark values.
const (
SessionEditCorrect = "correct"
SessionEditIncorrect = "incorrect"
)
3 changes: 3 additions & 0 deletions server/internal/handler/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -291,6 +291,9 @@ func (s *Server) Router(
r.Put("/session-messages/{id}", s.editSessionMessage)
r.Get("/session-messages/{id}/edit", s.getSessionMessageEdit)
r.Delete("/session-messages/{id}/edit", s.deleteSessionMessageEdit)
// Correctness review mark (correct|incorrect) on a raw session turn.
r.Put("/session-messages/{id}/mark", s.markSessionMessage)
r.Delete("/session-messages/{id}/mark", s.unmarkSessionMessage)
})

return r
Expand Down
12 changes: 12 additions & 0 deletions server/internal/handler/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -1813,6 +1813,10 @@ type sessionMessageResponse struct {
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
ChainSource *domain.ChainSource `json:"chain_source,omitempty"`
// Correctness review mark (correct|incorrect) if the turn has been
// marked. Raw browse shows the mark but never the content overlay — the
// content here is always the original session text.
Correctness string `json:"correctness,omitempty"`
}

func (s *Server) handleListSessionMessages(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -1875,6 +1879,13 @@ func (s *Server) handleListSessionMessages(w http.ResponseWriter, r *http.Reques
sessions = []*domain.Session{}
}
messages := make([]sessionMessageResponse, len(sessions))
ids := make([]string, 0, len(sessions))
for _, sess := range sessions {
ids = append(ids, sess.ID)
}
// Annotate raw turns with their correctness mark (if any). Content stays
// original — raw browse never applies the content overlay.
marks := svc.session.SessionCorrectnessByIDs(r.Context(), ids)
for i, sess := range sessions {
messages[i] = sessionMessageResponse{
ID: sess.ID,
Expand All @@ -1890,6 +1901,7 @@ func (s *Server) handleListSessionMessages(w http.ResponseWriter, r *http.Reques
State: sess.State,
CreatedAt: sess.CreatedAt,
UpdatedAt: sess.UpdatedAt,
Correctness: marks[sess.ID],
}
}
respond(w, http.StatusOK, map[string]any{
Expand Down
38 changes: 38 additions & 0 deletions server/internal/handler/memory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -362,6 +362,44 @@ func (s *testSessionRepo) DeleteSessionEdit(_ context.Context, id string) (int64
return 0, nil
}

func (s *testSessionRepo) MarkSessionEdit(_ context.Context, edit *domain.SessionEdit) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.overlays == nil {
s.overlays = map[string]*domain.SessionEdit{}
}
if existing, ok := s.overlays[edit.ID]; ok {
existing.Correctness = edit.Correctness // preserve content/tags
existing.Version++
return nil
}
cp := *edit // mark-only: no content override
cp.EditedContent = ""
cp.EditedContentSet = false
cp.Version = 1
if cp.State == "" {
cp.State = domain.StateActive
}
s.overlays[edit.ID] = &cp
return nil
}

func (s *testSessionRepo) ClearSessionEditMark(_ context.Context, id string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
ov, ok := s.overlays[id]
if !ok || ov.Correctness == "" {
return false, nil
}
if !ov.EditedContentSet {
delete(s.overlays, id) // mark-only row → remove
return true, nil
}
ov.Correctness = ""
ov.Version++
return true, nil
}

func intPtr(v int) *int {
return &v
}
Expand Down
58 changes: 58 additions & 0 deletions server/internal/handler/session_edit.go
Original file line number Diff line number Diff line change
Expand Up @@ -112,3 +112,61 @@ func (s *Server) deleteSessionMessageEdit(w http.ResponseWriter, r *http.Request
}
respond(w, http.StatusOK, map[string]any{"id": id, "reverted": removed > 0})
}

type markSessionMessageRequest struct {
Correctness string `json:"correctness"`
}

// markSessionMessage handles PUT /session-messages/{id}/mark: set the
// correctness review mark (correct|incorrect) on a raw session row without
// touching any content/tag overlay.
func (s *Server) markSessionMessage(w http.ResponseWriter, r *http.Request) {
auth := authInfo(r)
if auth.IsChain() {
s.handleError(r.Context(), w, &domain.ValidationError{
Field: "session_edit", Message: "raw session edit is not supported on chain keys",
})
return
}
id := chi.URLParam(r, "id")
if strings.TrimSpace(id) == "" {
s.handleError(r.Context(), w, &domain.ValidationError{Field: "id", Message: "required"})
return
}
var req markSessionMessageRequest
if err := decode(r, &req); err != nil {
s.handleError(r.Context(), w, err)
return
}
svc := s.resolveServices(auth)
edit, err := svc.session.MarkSessionOverlay(r.Context(), id, strings.TrimSpace(req.Correctness), auth.AgentName)
if err != nil {
s.handleError(r.Context(), w, err)
return
}
respond(w, http.StatusOK, map[string]any{
"id": edit.ID,
"correctness": edit.Correctness,
"version": edit.Version,
})
}

// unmarkSessionMessage handles DELETE /session-messages/{id}/mark: clear the
// correctness mark (and remove the overlay row if it had no content edit).
func (s *Server) unmarkSessionMessage(w http.ResponseWriter, r *http.Request) {
auth := authInfo(r)
if auth.IsChain() {
s.handleError(r.Context(), w, &domain.ValidationError{
Field: "session_edit", Message: "raw session edit is not supported on chain keys",
})
return
}
id := chi.URLParam(r, "id")
svc := s.resolveServices(auth)
cleared, err := svc.session.ClearSessionOverlayMark(r.Context(), id)
if err != nil {
s.handleError(r.Context(), w, err)
return
}
respond(w, http.StatusOK, map[string]any{"id": id, "unmarked": cleared})
}
163 changes: 163 additions & 0 deletions server/internal/handler/session_edit_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -215,3 +215,166 @@ func TestSessionSearch_ExplicitTagsOverride(t *testing.T) {
t.Fatalf("explicit tags must override, got %v", resp.Memories[0].Tags)
}
}

// --- correctness mark ---

func TestMarkSessionMessage_SetsAndUpdates(t *testing.T) {
sessionRepo := &testSessionRepo{getResult: sessionRow("turn-1", "original text")}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

mark := func(status string) map[string]any {
req := withURLParam(makeRequest(t, http.MethodPut, "/session-messages/turn-1/mark",
markSessionMessageRequest{Correctness: status}), "id", "turn-1")
rr := httptest.NewRecorder()
srv.markSessionMessage(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("mark %s status = %d: %s", status, rr.Code, rr.Body.String())
}
var resp map[string]any
_ = json.NewDecoder(rr.Body).Decode(&resp)
return resp
}

r1 := mark("correct")
if r1["correctness"] != "correct" || r1["version"].(float64) != 1 {
t.Fatalf("first mark = %v, want correct/v1", r1)
}
r2 := mark("incorrect")
if r2["correctness"] != "incorrect" || r2["version"].(float64) != 2 {
t.Fatalf("re-mark = %v, want incorrect/v2", r2)
}
// Mark-only: no content override stored.
if ov := sessionRepo.overlays["turn-1"]; ov == nil || ov.EditedContentSet {
t.Fatalf("mark-only row must have no content override: %+v", ov)
}
}

func TestMarkSessionMessage_InvalidValue(t *testing.T) {
srv := newTestServer(&testMemoryRepo{}, &testSessionRepo{getResult: sessionRow("turn-1", "x")})
req := withURLParam(makeRequest(t, http.MethodPut, "/session-messages/turn-1/mark",
markSessionMessageRequest{Correctness: "bogus"}), "id", "turn-1")
rr := httptest.NewRecorder()
srv.markSessionMessage(rr, req)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", rr.Code)
}
}

func TestMarkSessionMessage_NotFound(t *testing.T) {
srv := newTestServer(&testMemoryRepo{}, &testSessionRepo{})
req := withURLParam(makeRequest(t, http.MethodPut, "/session-messages/missing/mark",
markSessionMessageRequest{Correctness: "correct"}), "id", "missing")
rr := httptest.NewRecorder()
srv.markSessionMessage(rr, req)
if rr.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", rr.Code)
}
}

func TestSessionSearch_MarkOnlySurfacesCorrectnessNoContentChange(t *testing.T) {
row := sessionRow("turn-1", "original text")
sessionRepo := &testSessionRepo{getResult: row, listResults: []domain.Memory{*row}, listTotal: 1}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

srv.markSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodPut,
"/session-messages/turn-1/mark", markSessionMessageRequest{Correctness: "incorrect"}), "id", "turn-1"))

rr := httptest.NewRecorder()
srv.listMemories(rr, makeRequest(t, http.MethodGet, "/memories?memory_type=session", nil))
var resp listResponse
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Memories) != 1 || resp.Memories[0].Content != "original text" {
t.Fatalf("mark-only must not change content: %+v", resp.Memories)
}
var meta map[string]any
_ = json.Unmarshal(resp.Memories[0].Metadata, &meta)
if meta["correctness"] != "incorrect" {
t.Fatalf("correctness not surfaced: %v", meta)
}
if _, edited := meta["edited"]; edited {
t.Fatalf("mark-only must not set edited marker: %v", meta)
}
}

func TestEditSessionMessage_AutoSetsCorrect(t *testing.T) {
sessionRepo := &testSessionRepo{getResult: sessionRow("turn-1", "original text")}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

srv.editSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodPut,
"/session-messages/turn-1", editSessionMessageRequest{Content: "fixed text"}), "id", "turn-1"))

rr := httptest.NewRecorder()
srv.getSessionMessageEdit(rr, withURLParam(makeRequest(t, http.MethodGet,
"/session-messages/turn-1/edit", nil), "id", "turn-1"))
var ov domain.SessionEdit
_ = json.NewDecoder(rr.Body).Decode(&ov)
if ov.Correctness != "correct" {
t.Fatalf("content edit must auto-set correct, got %q", ov.Correctness)
}
}

func TestUnmarkSessionMessage_DeletesMarkOnlyRow(t *testing.T) {
sessionRepo := &testSessionRepo{getResult: sessionRow("turn-1", "original text")}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

srv.markSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodPut,
"/session-messages/turn-1/mark", markSessionMessageRequest{Correctness: "incorrect"}), "id", "turn-1"))
dr := httptest.NewRecorder()
srv.unmarkSessionMessage(dr, withURLParam(makeRequest(t, http.MethodDelete,
"/session-messages/turn-1/mark", nil), "id", "turn-1"))
if dr.Code != http.StatusOK {
t.Fatalf("unmark status = %d", dr.Code)
}
if _, ok := sessionRepo.overlays["turn-1"]; ok {
t.Fatal("mark-only row must be removed after unmark")
}
}

func TestUnmarkSessionMessage_PreservesContentEdit(t *testing.T) {
sessionRepo := &testSessionRepo{getResult: sessionRow("turn-1", "original text")}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

// Content edit (auto-correct), then unmark — content overlay must survive.
srv.editSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodPut,
"/session-messages/turn-1", editSessionMessageRequest{Content: "fixed text"}), "id", "turn-1"))
srv.unmarkSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodDelete,
"/session-messages/turn-1/mark", nil), "id", "turn-1"))

ov := sessionRepo.overlays["turn-1"]
if ov == nil || ov.EditedContent != "fixed text" || ov.Correctness != "" {
t.Fatalf("unmark must keep content edit and clear mark: %+v", ov)
}
}

func TestRawBrowse_SurfacesCorrectnessNotContent(t *testing.T) {
row := sessionRow("turn-1", "original text")
sessionRepo := &testSessionRepo{
getResult: row,
sessionListResults: []*domain.Session{{ID: "turn-1", SessionID: "sess-1", Seq: 3, Role: "user", Content: "original text", ContentType: "text", State: domain.StateActive}},
}
srv := newTestServer(&testMemoryRepo{}, sessionRepo)

// Edit content (overlay) AND it auto-marks correct.
srv.editSessionMessage(httptest.NewRecorder(), withURLParam(makeRequest(t, http.MethodPut,
"/session-messages/turn-1", editSessionMessageRequest{Content: "edited text"}), "id", "turn-1"))

rr := httptest.NewRecorder()
srv.handleListSessionMessages(rr, makeRequest(t, http.MethodGet, "/session-messages?session_id=sess-1", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d: %s", rr.Code, rr.Body.String())
}
var resp struct {
Messages []sessionMessageResponse `json:"messages"`
}
_ = json.NewDecoder(rr.Body).Decode(&resp)
if len(resp.Messages) != 1 {
t.Fatalf("want 1 message, got %d", len(resp.Messages))
}
// Raw browse: original content, but correctness annotation present.
if resp.Messages[0].Content != "original text" {
t.Fatalf("raw browse must show original content, got %q", resp.Messages[0].Content)
}
if resp.Messages[0].Correctness != "correct" {
t.Fatalf("raw browse must surface correctness, got %q", resp.Messages[0].Correctness)
}
}
6 changes: 6 additions & 0 deletions server/internal/repository/factory.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,3 +151,9 @@ func (stubSessionRepo) GetSessionEditsByIDs(_ context.Context, _ []string) (map[
func (stubSessionRepo) DeleteSessionEdit(_ context.Context, _ string) (int64, error) {
return 0, fmt.Errorf("session edit: %w", domain.ErrNotSupported)
}
func (stubSessionRepo) MarkSessionEdit(_ context.Context, _ *domain.SessionEdit) error {
return fmt.Errorf("session edit: %w", domain.ErrNotSupported)
}
func (stubSessionRepo) ClearSessionEditMark(_ context.Context, _ string) (bool, error) {
return false, fmt.Errorf("session edit: %w", domain.ErrNotSupported)
}
5 changes: 5 additions & 0 deletions server/internal/repository/repository.go
Original file line number Diff line number Diff line change
Expand Up @@ -131,4 +131,9 @@ type SessionRepo interface {
GetSessionEdit(ctx context.Context, id string) (*domain.SessionEdit, error)
GetSessionEditsByIDs(ctx context.Context, ids []string) (map[string]*domain.SessionEdit, error)
DeleteSessionEdit(ctx context.Context, id string) (int64, error)
// MarkSessionEdit upserts only the correctness mark (correct|incorrect),
// preserving any existing content/tag overlay. ClearSessionEditMark
// removes the mark and deletes the row if it had no content override.
MarkSessionEdit(ctx context.Context, edit *domain.SessionEdit) error
ClearSessionEditMark(ctx context.Context, id string) (bool, error)
}
Loading
Loading