Files
artex/server/side_questions_test.go
T
dela 0335d572de
ci / go (push) Waiting to run
ci / go-db (agent) (push) Waiting to run
ci / go-db (config) (push) Waiting to run
ci / go-db (db) (push) Waiting to run
ci / go-db (evidence) (push) Waiting to run
ci / go-db (llmrec) (push) Waiting to run
ci / go-db (server) (push) Waiting to run
web / web (push) Waiting to run
docs / links (push) Canceled after 0s
detections / detections (push) Canceled after 0s
First Commit
2026-10-09 08:38:16 +08:00

535 lines
18 KiB
Go

package server
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"iter"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/db"
"github.com/Autumn-27/artex/sidequestion"
"github.com/Autumn-27/norma/harness"
"github.com/Autumn-27/norma/llm"
)
type sideHTTPProvider struct {
started chan llm.CompletionRequest
release chan struct{}
summary func(context.Context, llm.CompletionRequest) (llm.Message, llm.Usage, error)
}
func (p *sideHTTPProvider) Stream(ctx context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamEvent, error] {
return func(y func(llm.StreamEvent, error) bool) {
p.started <- req
if !y(llm.StreamEvent{Type: llm.SEMessageStart, Usage: llm.Usage{InputTokens: 19}}, nil) {
return
}
if !y(llm.StreamEvent{Type: llm.SETextDelta, Text: "partial answer"}, nil) {
return
}
select {
case <-p.release:
y(llm.StreamEvent{Type: llm.SEMessageStop}, nil)
case <-ctx.Done():
y(llm.StreamEvent{}, ctx.Err())
}
}
}
func (p *sideHTTPProvider) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
if req.Thinking == "disabled" && p.summary != nil {
msg, usage, err := p.summary(ctx, req)
return msg, "end_turn", usage, err
}
for _, err := range p.Stream(ctx, req) {
if err != nil {
return llm.Message{}, "", llm.Usage{}, err
}
}
return llm.Message{Role: llm.RoleAssistant, Content: []llm.ContentBlock{llm.TextBlock("atomic answer")}}, "end_turn", llm.Usage{InputTokens: 19}, nil
}
func TestSideHTTPPreparationCancellationAndClear(t *testing.T) {
for _, clear := range []bool{false, true} {
t.Run(fmt.Sprint(clear), func(t *testing.T) {
f := newSideHTTPFixture(t)
p, path := f.conversation(t)
snap := f.checkpoint(t, p)
for i := 0; i < 21; i++ {
e, _, err := f.m.pg.StartSideRequest(t.Context(), snap, fmt.Sprint(i), "past question")
if err != nil {
t.Fatal(err)
}
e.Answer = "old answer"
e.Sequence = 1
e.Status = "completed"
if _, err = f.m.pg.UpdateSideRequest(t.Context(), *e); err != nil {
t.Fatal(err)
}
}
started := make(chan struct{})
f.provider.summary = func(ctx context.Context, req llm.CompletionRequest) (llm.Message, llm.Usage, error) {
if len(req.Tools) != 0 {
t.Error("summary has tools")
}
close(started)
<-ctx.Done()
return llm.UserText("late summary"), llm.Usage{InputTokens: 17}, nil
}
e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "follow-up", "client_request_id": "new"}, 202))
select {
case <-started:
case <-time.After(time.Second):
t.Fatal("summary did not start")
}
row, err := f.m.pg.SideRequest(t.Context(), e.ID)
if err != nil || row.Context.Phase != "summarizing_history" {
t.Fatalf("phase not persisted: %+v %v", row, err)
}
// A blocked summary must not hold the global admission/clear mutex.
other, otherPath := f.conversation(t)
f.checkpoint(t, other)
f.call(t, "POST", otherPath, map[string]string{"question": "other session", "client_request_id": "independent"}, 202)
f.s.side.mu.Lock()
done := f.s.side.runs[e.ID].done
f.s.side.mu.Unlock()
if clear {
f.call(t, "DELETE", path, nil, 200)
} else {
f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200)
}
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("summary cancellation did not settle")
}
row, err = f.m.pg.SideRequest(t.Context(), e.ID)
if err != nil {
t.Fatal(err)
}
if clear {
if row != nil {
t.Fatal("cleared request reappeared")
}
} else if row.Status != "cancelled" || row.Usage.InputTokens != 17 {
t.Fatalf("cancel usage: %+v", row)
}
var memory string
if err = f.m.pg.QueryRow(`SELECT memory::text FROM side_question_sessions WHERE session_key=$1`, p.Key()).Scan(&memory); err != nil || memory != "{}" {
t.Fatalf("late summary saved: %s %v", memory, err)
}
})
}
}
type sideHTTPFixture struct {
s *Server
m *Manager
provider *sideHTTPProvider
handler http.Handler
token string
}
func newSideHTTPFixture(t *testing.T) *sideHTTPFixture {
t.Helper()
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
ctx, cancel := context.WithCancel(context.Background())
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, jwtKey: []byte("btw-test-signing-key-only"), chatBusy: map[string]bool{}}
s.initSideQuestions()
p := &sideHTTPProvider{started: make(chan llm.CompletionRequest, 16), release: make(chan struct{})}
cfg := agent.Config{Format: llm.FormatOpenAI, BaseURL: "http://fixture.invalid", Model: "fixture", Stream: true}
s.cfgMu.Lock()
s.llmCfg = cfg
s.llmOn = true
s.llmDirect = bindSideProvider(p, cfg, 0, "fixture")
s.cfgMu.Unlock()
token, err := signJWT(s.jwtKey)
if err != nil {
t.Fatal(err)
}
f := &sideHTTPFixture{s: s, m: m, provider: p, handler: s.Handler(), token: token}
t.Cleanup(func() {
cancel()
for _, done := range s.cancelSideWhere(func(sidequestion.Parent) bool { return true }) {
<-done
}
<-s.side.done
s.flushSideSnapshots()
m.Close()
})
return f
}
func (f *sideHTTPFixture) call(t *testing.T, method, path string, body any, want int) *httptest.ResponseRecorder {
t.Helper()
raw, _ := json.Marshal(body)
req := httptest.NewRequest(method, path, bytes.NewReader(raw))
req.Header.Set("Authorization", "Bearer "+f.token)
w := httptest.NewRecorder()
f.handler.ServeHTTP(w, req)
if w.Code != want {
t.Fatalf("%s %s: status=%d want=%d body=%s", method, path, w.Code, want, w.Body.String())
}
return w
}
func (f *sideHTTPFixture) conversation(t *testing.T) (sidequestion.Parent, string) {
t.Helper()
c, err := f.m.pg.CreateConversation("mainagent", "side HTTP", nil)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = f.m.pg.DeleteConversation(c.ID) })
return sidequestion.Parent{ConversationID: c.ID}, fmt.Sprintf("/api/conversations/%d/side-questions", c.ID)
}
func (f *sideHTTPFixture) checkpoint(t *testing.T, p sidequestion.Parent) sidequestion.Snapshot {
t.Helper()
snap := sidequestion.Snapshot{Parent: p, RunID: time.Now().UnixNano(), Version: 1, CapturedAt: time.Now().UTC(), Model: sideModel(f.s.llmCfg, 0, "fixture"), Request: llm.CompletionRequest{MaxTokens: 128, Messages: []llm.Message{llm.UserText("main context marker")}}}
if err := f.m.pg.SaveSideSnapshot(t.Context(), snap); err != nil {
t.Fatal(err)
}
return snap
}
func decodeSide(t *testing.T, w *httptest.ResponseRecorder) sidequestion.Exchange {
t.Helper()
var e sidequestion.Exchange
if err := json.Unmarshal(w.Body.Bytes(), &e); err != nil {
t.Fatal(err)
}
return e
}
func waitSide(t *testing.T, d *db.DB, id, status string) *sidequestion.Exchange {
t.Helper()
until := time.Now().Add(3 * time.Second)
for time.Now().Before(until) {
e, err := d.SideRequest(t.Context(), id)
if err != nil {
t.Fatal(err)
}
if e != nil && e.Status == status {
return e
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("side %s did not reach %s", id, status)
return nil
}
func TestSideHTTPBusyIsolationClearAndReconnect(t *testing.T) {
f := newSideHTTPFixture(t)
p, path := f.conversation(t)
f.call(t, "POST", path, map[string]string{"question": "old session", "client_request_id": "old"}, 409)
f.checkpoint(t, p)
// Main busy status must not reject the side request.
f.s.chatMu.Lock()
f.s.chatBusy[fmt.Sprintf("conv-%d", p.ConversationID)] = true
f.s.chatMu.Unlock()
e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "question", "client_request_id": "one"}, 202))
select {
case req := <-f.provider.started:
if !strings.Contains(req.Messages[0].Text(), "main context marker") {
t.Fatal("missing checkpoint")
}
case <-time.After(time.Second):
t.Fatal("side blocked by main busy")
}
again := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "question", "client_request_id": "one"}, 200))
if again.ID != e.ID {
t.Fatal("duplicate request re-executed")
}
f.call(t, "POST", path, map[string]string{"question": "another", "client_request_id": "two"}, 409)
// SSE reconnect always starts with a cumulative snapshot even with Last-Event-ID.
httpServer := httptest.NewServer(f.handler)
defer httpServer.Close()
for i := 0; i < 2; i++ {
req, _ := http.NewRequest("GET", httpServer.URL+"/api/side-questions/"+e.ID+"/events", nil)
req.Header.Set("Authorization", "Bearer "+f.token)
req.Header.Set("Last-Event-ID", "999")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(resp.Body)
seen := false
for scanner.Scan() {
if strings.HasPrefix(scanner.Text(), "data: ") {
seen = strings.Contains(scanner.Text(), e.ID)
break
}
}
resp.Body.Close()
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
if !seen {
t.Fatal("reconnect did not return cumulative request")
}
}
f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200)
done := waitSide(t, f.m.pg, e.ID, "cancelled")
if done.Answer != "partial answer" || done.Usage.InputTokens != 19 {
t.Fatalf("cancel lost partial/usage: %+v", done)
}
f.s.chatMu.Lock()
mainBusy := f.s.chatBusy[fmt.Sprintf("conv-%d", p.ConversationID)]
f.s.chatMu.Unlock()
if !mainBusy {
t.Fatal("side cancellation stopped main")
}
f.call(t, "DELETE", path, nil, 200)
if history, err := f.m.pg.SideHistory(t.Context(), p.Key(), 0, 20); err != nil || len(history) != 0 {
t.Fatalf("clear %+v %v", history, err)
}
if snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key()); err != nil || snap == nil {
t.Fatal("clear lost main snapshot")
}
var count int
if err := f.m.pg.QueryRow(`SELECT count(*) FROM conversation_activities WHERE conversation_id=$1`, p.ConversationID).Scan(&count); err != nil || count != 0 {
t.Fatalf("side contaminated main activity %d %v", count, err)
}
f.s.cfgMu.Lock()
f.s.llmCfg.Model = "changed"
f.s.cfgMu.Unlock()
f.call(t, "GET", path, nil, 200)
f.call(t, "POST", path, map[string]string{"question": "changed config", "client_request_id": "new"}, 409)
}
func TestSideHTTPGlobalLimitTaskWorkerAndDeletion(t *testing.T) {
f := newSideHTTPFixture(t)
var requests []sidequestion.Exchange
for i := 0; i < 20; i++ {
p, path := f.conversation(t)
f.checkpoint(t, p)
want := 202
if i >= 4 {
want = 429
}
w := f.call(t, "POST", path, map[string]string{"question": "blocked", "client_request_id": "id"}, want)
if want == 202 {
requests = append(requests, decodeSide(t, w))
}
}
for _, e := range requests {
f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200)
waitSide(t, f.m.pg, e.ID, "cancelled")
}
close(f.provider.release)
task, err := f.m.CreateTask("side route task", "context", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
id, _ := strconv.ParseInt(task.ID, 10, 64)
t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) })
iid, err := task.Store.AddNode(db.KindIntent, map[string]any{"summary": "own worker"}, 1, "paused", "planner", nil)
if err != nil {
t.Fatal(err)
}
for _, intent := range []int64{0, iid} {
p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: intent}
f.checkpoint(t, p)
path := "/api/tasks/" + task.ID + "/chat/side-questions"
if intent > 0 {
path = fmt.Sprintf("/api/tasks/%s/intents/%d/side-questions", task.ID, intent)
}
e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "which context", "client_request_id": "own"}, 202))
waitSide(t, f.m.pg, e.ID, "completed")
}
f.call(t, "GET", fmt.Sprintf("/api/tasks/%s/intents/%d/side-questions", task.ID, iid+1000000), nil, 404)
if _, err := f.s.applyIntentControl(t.Context(), task, iid, "cancel", "cleanup", ""); err != nil {
t.Fatal(err)
}
if snap, err := f.m.pg.SideSnapshot(t.Context(), (sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: iid}).Key()); err != nil || snap != nil {
t.Fatal("stopped intent side snapshot remains")
}
}
func TestSideCheckpointPersistsBeforeAdmissionAndRestart(t *testing.T) {
f := newSideHTTPFixture(t)
p, path := f.conversation(t)
bound := bindSideProvider(f.provider, f.s.llmCfg, 0, "fixture")
ctx, deps := sidequestion.Attach(f.s.ctx, p, harness.QueryDeps{}, bound)
mainCtx, stopMain := context.WithCancel(ctx)
done := make(chan struct{})
go func() {
defer close(done)
for range deps.CallModel(mainCtx, llm.CompletionRequest{Messages: []llm.Message{llm.UserText("live checkpoint")}, MaxTokens: 128}) {
}
}()
select {
case <-f.provider.started:
case <-time.After(time.Second):
t.Fatal("main never started")
}
e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "side", "client_request_id": "live"}, 202))
snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key())
if err != nil || snap == nil || snap.Request.Messages[0].Text() != "live checkpoint" {
t.Fatalf("admission checkpoint not durable %+v %v", snap, err)
}
stopMain()
<-done
f.call(t, "POST", "/api/side-questions/"+e.ID+"/cancel", nil, 200)
waitSide(t, f.m.pg, e.ID, "cancelled")
// Simulate an abrupt process exit: persisted running row survives, memory doesn't.
orphan, _, err := f.m.pg.StartSideRequest(t.Context(), *snap, "orphan", "restart")
if err != nil {
t.Fatal(err)
}
orphan.Answer = "saved before crash"
orphan.Sequence = 1
if _, err = f.m.pg.UpdateSideRequest(t.Context(), *orphan); err != nil {
t.Fatal(err)
}
ctx2, cancel := context.WithCancel(t.Context())
defer cancel()
restarted := &Server{m: f.m, ctx: ctx2, llmCfg: f.s.llmCfg, llmDirect: bound, llmOn: true}
restarted.initSideQuestions()
if got := waitSide(t, f.m.pg, orphan.ID, "interrupted"); got.Answer != "saved before crash" {
t.Fatal("restart discarded partial answer")
}
restored, err := restarted.sideSnapshot(t.Context(), p)
if err != nil || restored == nil {
t.Fatal("restart missing snapshot")
}
if _, err = restarted.sideProvider(restored.Model); err != nil {
t.Fatal(err)
}
if req, err := sidequestion.BuildRequest(*restored, nil, "continue after restart"); err != nil || req.Messages[0].Text() != "live checkpoint" {
t.Fatal("restart cannot ask from stored context")
}
// All routes pass the same authentication middleware.
w := httptest.NewRecorder()
f.handler.ServeHTTP(w, httptest.NewRequest("GET", path, nil))
if w.Code != 401 {
t.Fatalf("unauthenticated side route: %d", w.Code)
}
}
func TestSideRejectsDeletedOrChangedCachedProfile(t *testing.T) {
f := newSideHTTPFixture(t)
p := &db.LLMProfile{Name: "btw-cached-profile", Format: "openai", BaseURL: "http://fixture.invalid", APIKey: "test-only", Model: "fixture", Streaming: true}
id, err := f.m.pg.SaveProfile(p)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = f.m.pg.DeleteProfile(id) })
cfg, ok := f.s.loadProfileConfig(id)
if !ok {
t.Fatal("profile unavailable")
}
f.s.provByProfile = map[int64]*provEntry{id: {prov: f.provider, cfg: cfg}}
model := sideModel(cfg, id, p.Name)
if _, err = f.s.sideProvider(model); err != nil {
t.Fatal(err)
}
if _, err = f.m.pg.Exec(`UPDATE llm_profiles SET model='changed' WHERE id=$1`, id); err != nil {
t.Fatal(err)
}
if _, err = f.s.sideProvider(model); err == nil {
t.Fatal("cached profile bypassed model identity check")
}
if err = f.m.pg.DeleteProfile(id); err != nil {
t.Fatal(err)
}
if _, err = f.s.sideProvider(model); err == nil {
t.Fatal("deleted profile remained usable from cache")
}
}
func TestSideTaskDrainPersistsBeforeArchive(t *testing.T) {
f := newSideHTTPFixture(t)
task, err := f.m.CreateTask("side archive drain", "context", nil, 0, 0)
if err != nil {
t.Fatal(err)
}
id, _ := strconv.ParseInt(task.ID, 10, 64)
t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) })
p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID}
f.checkpoint(t, p)
path := "/api/tasks/" + task.ID + "/chat/side-questions"
e := decodeSide(t, f.call(t, "POST", path, map[string]string{"question": "answer before archive", "client_request_id": "drain"}, 202))
select {
case <-f.provider.started:
case <-time.After(time.Second):
t.Fatal("side never started")
}
// The real archive entry point closes this same admission barrier first.
if !f.s.beginTaskDelete(task.ID) {
t.Fatal("cannot close task admission")
}
defer f.s.abortTaskDelete(task.ID)
f.call(t, "POST", path, map[string]string{"question": "too late", "client_request_id": "late"}, 409)
ctx, cancel := context.WithTimeout(t.Context(), 3*time.Second)
defer cancel()
if err := f.s.drainTaskSideQuestions(ctx, task.ID); err != nil {
t.Fatal(err)
}
row, err := f.m.pg.SideRequest(t.Context(), e.ID)
if err != nil || row == nil || row.Status != "cancelled" || row.Answer != "partial answer" || row.Usage.InputTokens != 19 {
t.Fatalf("drain returned before final persistence: %+v %v", row, err)
}
archive, err := f.m.pg.SnapshotTaskArchive(id)
if err != nil {
t.Fatal(err)
}
var rows []sidequestion.Exchange
if err := json.Unmarshal(archive.Tables["side_question_requests"], &rows); err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Status != "cancelled" || rows[0].Usage.InputTokens != 19 {
t.Fatalf("archive lost drained side answer/usage: %+v", rows)
}
}
func TestSideRestoredWorkerRuntimePublishesNewCheckpoint(t *testing.T) {
f := newSideHTTPFixture(t)
task, err := f.m.CreateTask("restore side publisher", "context", nil, 3600, 0)
if err != nil {
t.Fatal(err)
}
id, _ := strconv.ParseInt(task.ID, 10, 64)
t.Cleanup(func() { _ = f.m.pg.DeleteTask(id) })
if err := f.m.pg.SetPaused(id, true); err != nil {
t.Fatal(err)
}
iid, err := task.Store.AddNode(db.KindIntent, map[string]any{"summary": "restored worker"}, 1, "paused", "planner", nil)
if err != nil {
t.Fatal(err)
}
// Exercise the production startup path, including the deadline coordinator
// which registers the shared runtime before the worker loops.
f.s.restoreTaskRuntimes()
t.Cleanup(func() {
for _, task := range f.m.List() {
f.s.engine.StopTask(task.ID)
}
})
f.s.engine.runtimeMu.Lock()
runtime := f.s.engine.runtimes[task.ID]
f.s.engine.runtimeMu.Unlock()
if runtime == nil {
t.Fatal("restored task runtime missing")
}
p := sidequestion.Parent{TaskID: id, ExplorationID: task.ExpID, IntentID: iid}
close(f.provider.release)
bound := bindSideProvider(f.provider, f.s.llmCfg, 0, "fixture")
ctx, deps := sidequestion.Attach(runtime.ctx, p, harness.QueryDeps{}, bound)
_, _, _, err = deps.CallModelSync(ctx, llm.CompletionRequest{Messages: []llm.Message{llm.UserText("after restart context")}})
if err != nil {
t.Fatal(err)
}
f.s.flushSideSnapshots()
snap, err := f.m.pg.SideSnapshot(t.Context(), p.Key())
if err != nil || snap == nil || snap.Request.Messages[0].Text() != "after restart context" {
t.Fatalf("restored runtime lost checkpoint publisher: %+v %v", snap, err)
}
}