CI / test (push) Successful in 1m39s
The storage half of continuing a thread on a different model than it started on. Nothing uses it yet. Until now llmgw stored no message content anywhere: usage rows carry token counts, costs and latencies, never text, and even its own chat page keeps the conversation in a JavaScript variable that a refresh discards. That property is worth more than the feature, so it is the default and stays the default. A project stores nothing until someone turns it on for that project. Retention is a number of hours rather than a boolean, because a thread store with no expiry is a transcript of everything a team ever typed, kept forever because nobody chose a number. Turning it on means saying for how long. The window measures idleness rather than age — expiring a conversation someone is still having would be a strange way to keep that promise. Threads are scoped to a project and the scope is part of every lookup, not a check afterwards. Reading a thread from another project and reading one that does not exist return the same error: two errors would let a caller learn which ids are real in projects they cannot read. Writing across projects is refused for the same reason in the other direction — append, then read your own reply back. Deleting a thread takes its messages with it, which the test checks by counting rows rather than by trusting the cascade. Someone has to be able to say "forget that", and a retention window is no answer to a message sent by mistake. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
200 lines
6.7 KiB
Go
200 lines
6.7 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestAProjectKeepsNoConversationsUnlessItAsks(t *testing.T) {
|
|
// The property being given up here is worth protecting in a test: until a
|
|
// project turns this on, llmgw stores no message content anywhere. Every
|
|
// project that existed before threads did, and every one created since
|
|
// without asking, must stay that way — a gateway that quietly starts
|
|
// keeping what a team types is a much harder thing to have deployed.
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
proj := tree(t, s)
|
|
|
|
if d, err := s.ThreadRetention(ctx, proj); err != nil || d != 0 {
|
|
t.Fatalf("a new project retains %v (err %v), want none", d, err)
|
|
}
|
|
|
|
err := s.AppendThread(ctx, proj, "th1", []Message{{Role: "user", Content: "secret"}})
|
|
if !errors.Is(err, ErrThreadsNotEnabled) {
|
|
t.Fatalf("storing into a project that never asked returned %v", err)
|
|
}
|
|
|
|
// And nothing was written on the way to refusing.
|
|
if _, err := s.Thread(ctx, proj, "th1"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("a refused append left a thread behind: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestATurnedOnProjectStoresAndReplaysInOrder(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
turns := []Message{
|
|
{Role: "user", Content: "first"},
|
|
{Role: "assistant", Content: "answer one", Model: "small"},
|
|
{Role: "user", Content: "second"},
|
|
{Role: "assistant", Content: "answer two", Model: "large"},
|
|
}
|
|
for _, m := range turns {
|
|
if err := s.AppendThread(ctx, proj, "th1", []Message{m}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
got, err := s.Thread(ctx, proj, "th1")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(got) != len(turns) {
|
|
t.Fatalf("stored %d turns, read back %d", len(turns), len(got))
|
|
}
|
|
for i := range turns {
|
|
if got[i].Role != turns[i].Role || got[i].Content != turns[i].Content {
|
|
t.Errorf("turn %d came back as %+v", i, got[i])
|
|
}
|
|
}
|
|
// Which model answered, because a thread that moved between models
|
|
// otherwise reads as one voice — and when the answers disagree that is the
|
|
// first thing anyone asks.
|
|
if got[1].Model != "small" || got[3].Model != "large" {
|
|
t.Errorf("the models that answered were lost: %q and %q", got[1].Model, got[3].Model)
|
|
}
|
|
}
|
|
|
|
func TestAnotherProjectsThreadIsNotReadable(t *testing.T) {
|
|
// Thread ids are opaque, but an unguessable id is a secret, and secrets end
|
|
// up in logs, shell history and bug reports. The project scope is what
|
|
// makes a leaked id useless to anyone outside it.
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
mine := tree(t, s)
|
|
if err := s.PutProject(ctx, Project{ID: "proj2", TeamID: "team1", Name: "Other"}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, p := range []string{mine, "proj2"} {
|
|
if err := s.SetThreadRetention(ctx, p, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if err := s.AppendThread(ctx, mine, "th1", []Message{{Role: "user", Content: "private"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// The same error as a thread that does not exist. Two errors would let a
|
|
// caller learn which ids are real in projects they cannot read.
|
|
if _, err := s.Thread(ctx, "proj2", "th1"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("reading across projects returned %v", err)
|
|
}
|
|
if _, err := s.Thread(ctx, "proj2", "never-existed"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("a missing thread returned a different error: %v", err)
|
|
}
|
|
// Writing across projects is the same hole in the other direction: append,
|
|
// then read your own reply back.
|
|
if err := s.AppendThread(ctx, "proj2", "th1", []Message{{Role: "user", Content: "x"}}); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("writing into another project's thread returned %v", err)
|
|
}
|
|
}
|
|
|
|
func TestDeletingAThreadTakesItsMessagesWithIt(t *testing.T) {
|
|
// Somebody has to be able to say "forget that", and a retention window is
|
|
// no answer to a message sent by mistake.
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.AppendThread(ctx, proj, "th1", []Message{{Role: "user", Content: "oops"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := s.DeleteThread(ctx, proj, "th1"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if _, err := s.Thread(ctx, proj, "th1"); !errors.Is(err, ErrNoThread) {
|
|
t.Errorf("the thread survived deletion: %v", err)
|
|
}
|
|
|
|
// The rows, not just the row. Without the cascade the content would still
|
|
// be in the database after someone was told it was gone.
|
|
var left int
|
|
if err := s.db.QueryRowContext(ctx,
|
|
`SELECT COUNT(*) FROM thread_messages WHERE thread_id = ?`, "th1").Scan(&left); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if left != 0 {
|
|
t.Errorf("%d messages survived the thread being deleted", left)
|
|
}
|
|
}
|
|
|
|
func TestRetentionIsMeasuredFromTheLastTurnNotTheFirst(t *testing.T) {
|
|
// Expiring a conversation someone is still having would be a strange way
|
|
// to keep a promise about how long things are kept.
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 1); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.AppendThread(ctx, proj, "live", []Message{{Role: "user", Content: "hello"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
// Two hours on, a thread touched now is still inside a one-hour window.
|
|
if n, err := s.PruneThreads(ctx, time.Now().Add(2*time.Hour)); err != nil {
|
|
t.Fatal(err)
|
|
} else if n != 1 {
|
|
t.Fatalf("an idle thread past its window was not pruned: %d", n)
|
|
}
|
|
|
|
if err := s.AppendThread(ctx, proj, "live2", []Message{{Role: "user", Content: "hello"}}); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if n, err := s.PruneThreads(ctx, time.Now()); err != nil {
|
|
t.Fatal(err)
|
|
} else if n != 0 {
|
|
t.Errorf("a thread inside its window was pruned: %d", n)
|
|
}
|
|
}
|
|
|
|
func TestPruningLeavesProjectsThatStoreNothingAlone(t *testing.T) {
|
|
// They have nothing to prune, and a sweep that treated "no retention" as
|
|
// "retain for zero hours" would be indistinguishable from one that worked.
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
tree(t, s)
|
|
|
|
if n, err := s.PruneThreads(ctx, time.Now()); err != nil || n != 0 {
|
|
t.Errorf("pruning removed %d rows in a store with no threads (err %v)", n, err)
|
|
}
|
|
}
|
|
|
|
func TestTurningRetentionOffAgain(t *testing.T) {
|
|
ctx := context.Background()
|
|
s := openDB(t)
|
|
proj := tree(t, s)
|
|
if err := s.SetThreadRetention(ctx, proj, 24); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := s.SetThreadRetention(ctx, proj, 0); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if d, err := s.ThreadRetention(ctx, proj); err != nil || d != 0 {
|
|
t.Fatalf("retention is %v after being turned off (err %v)", d, err)
|
|
}
|
|
if err := s.AppendThread(ctx, proj, "th1", []Message{{Role: "user", Content: "x"}}); !errors.Is(err, ErrThreadsNotEnabled) {
|
|
t.Errorf("storing continued after being turned off: %v", err)
|
|
}
|
|
}
|