aee20a6abc
llama-server is started without -np, so it serves one request at a time and everything else queues. Mail extraction is allowed two minutes on a Thinking 1.7B, and the reader hands core up to 25 messages back to back. A turn arriving mid-extraction therefore waited for whatever was left of that budget: the router timed out into the classifier cascade and its 36.8% floor, and the phraser, which has no floor, simply waited. Memory evaluation had the same shape with a five minute budget. llm.Gate is the bound. Foreground requests never wait. Background requests run one at a time and yield while a foreground request is in flight, plus a quiet window after it that covers the gap between the router call and the phraser call of one turn. Clients get their priority from llmClientFor or llmBackgroundClientFor, so which side a caller is on is decided at wiring time. It gates only what goes through those clients, which the comment on Gate says. mail intake: the extraction timeout no longer wraps the capture writes. A model answering at 119 seconds of a 120 second budget left the first CaptureTask one second and the third none, so candidates the model had already produced were dropped with a deadline error. The mailbox name is validated before it becomes provenance, since "email:" is not a source and neither is an arbitrary string posted at the socket. The enable log prints the normalised candidate bound rather than the configured one, which said "max 0" and then wrote three. Found in review of #64. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
242 lines
7.8 KiB
Go
242 lines
7.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/config"
|
|
"github.com/kami/maven/internal/email"
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/llm"
|
|
"github.com/kami/maven/internal/store"
|
|
)
|
|
|
|
// mailLLM — a canned extraction reply.
|
|
type mailLLM struct {
|
|
reply string
|
|
calls int
|
|
}
|
|
|
|
func (m *mailLLM) Complete(_ context.Context, _ llm.Req) (string, error) {
|
|
m.calls++
|
|
return m.reply, nil
|
|
}
|
|
|
|
func newTestIntake(t *testing.T, reply string) (*mailIntake, *store.Store, *mailLLM) {
|
|
t.Helper()
|
|
st := newTestStore(t)
|
|
fake := &mailLLM{reply: reply}
|
|
return &mailIntake{
|
|
st: st,
|
|
ex: email.NewExtractor(fake, 0, nil),
|
|
timeout: 5 * time.Second,
|
|
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
|
}, st, fake
|
|
}
|
|
|
|
func ingestReq() ipc.IngestMailReq {
|
|
return ipc.IngestMailReq{
|
|
Mailbox: "INBOX", UID: 42,
|
|
From: "billing@isp.example",
|
|
Subject: "Счёт за интернет",
|
|
Body: "Оплатите счёт до 5 августа.",
|
|
}
|
|
}
|
|
|
|
// The one property that matters: a mail-derived task is a candidate, attributed
|
|
// to the mailbox, with the subject as reviewable evidence — and nothing else is
|
|
// written.
|
|
func TestIngestCapturesCandidates(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт за интернет","due":"2026-08-05"}]`)
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 1 || len(resp.TaskIDs) != 1 {
|
|
t.Fatalf("resp = %+v, want one created task", resp)
|
|
}
|
|
tasks, err := st.ListTasks(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("list: %v", err)
|
|
}
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
got := tasks[0]
|
|
if got.Status != store.TaskCandidate {
|
|
t.Errorf("status = %q, want %q — mail may only produce candidates", got.Status, store.TaskCandidate)
|
|
}
|
|
if got.Source != "email:INBOX" {
|
|
t.Errorf("source = %q, want email:INBOX", got.Source)
|
|
}
|
|
if got.Evidence != "Счёт за интернет" {
|
|
t.Errorf("evidence = %q, want the subject line", got.Evidence)
|
|
}
|
|
if got.Due == nil || got.Due.Format("2006-01-02") != "2026-08-05" {
|
|
t.Errorf("due = %v, want 2026-08-05", got.Due)
|
|
}
|
|
// Nothing else may have been written: no reminder, no fact.
|
|
rem, err := st.ListReminders(context.Background(), 10)
|
|
if err != nil {
|
|
t.Fatalf("list reminders: %v", err)
|
|
}
|
|
if len(rem) != 0 {
|
|
t.Errorf("mail created %d reminders; a misread mail must never be able to fire", len(rem))
|
|
}
|
|
}
|
|
|
|
// Re-reading a mailbox must not grow the list — CaptureTask dedupes among live
|
|
// rows, and the intake relies on exactly that.
|
|
func TestIngestSameMailTwiceIsIdempotent(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"оплатить счёт","due":""}]`)
|
|
if _, err := mi.ingest(context.Background(), ingestReq()); err != nil {
|
|
t.Fatalf("first ingest: %v", err)
|
|
}
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("second ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 1 {
|
|
t.Errorf("resp = %+v, want the existing row and Created=0", resp)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Errorf("got %d tasks after two reads, want 1", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestJunkSkipsTheModel(t *testing.T) {
|
|
mi, st, fake := newTestIntake(t, `[{"text":"купить со скидкой","due":""}]`)
|
|
req := ingestReq()
|
|
req.Junk = true
|
|
resp, err := mi.ingest(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if !resp.Skipped || resp.Created != 0 {
|
|
t.Errorf("resp = %+v, want skipped", resp)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for junk, want 0", fake.calls)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("junk produced %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestEmptyMessageSkipped(t *testing.T) {
|
|
mi, _, fake := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ipc.IngestMailReq{Mailbox: "INBOX", UID: 1})
|
|
if err != nil || !resp.Skipped {
|
|
t.Fatalf("resp = %+v, err = %v; want skipped", resp, err)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("model called %d times for an empty message, want 0", fake.calls)
|
|
}
|
|
}
|
|
|
|
func TestIngestNoTasksWritesNothing(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, "[]")
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 0 || len(resp.TaskIDs) != 0 || resp.Skipped {
|
|
t.Errorf("resp = %+v, want nothing captured and not skipped", resp)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("got %d tasks, want 0", len(tasks))
|
|
}
|
|
}
|
|
|
|
func TestIngestTruncatesEvidence(t *testing.T) {
|
|
mi, st, _ := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
|
req := ingestReq()
|
|
req.Subject = strings.Repeat("щ", 400)
|
|
if _, err := mi.ingest(context.Background(), req); err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
tasks, _ := st.ListTasks(context.Background(), "")
|
|
if len(tasks) != 1 {
|
|
t.Fatalf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
if n := len([]rune(tasks[0].Evidence)); n > evidenceMaxChars+1 {
|
|
t.Errorf("evidence kept %d runes, want ≤ %d", n, evidenceMaxChars)
|
|
}
|
|
}
|
|
|
|
// Off unless configured: no email block ⇒ no intake, so the IPC method does not
|
|
// exist at all.
|
|
func TestNewMailIntakeOffWithoutConfig(t *testing.T) {
|
|
st := newTestStore(t)
|
|
if mi := newMailIntake(st, nil, &config.Config{}, nil); mi != nil {
|
|
t.Error("no email block must mean no mail intake")
|
|
}
|
|
// Configured but with a non-LLM phraser: still off — there is no fallback
|
|
// extraction, by design.
|
|
if mi := newMailIntake(st, nil, &config.Config{Email: &config.EmailConfig{}}, nil); mi != nil {
|
|
t.Error("without a llama-server phraser there is nothing to extract with")
|
|
}
|
|
}
|
|
|
|
// The mailbox name becomes the provenance string, which is the vocabulary the
|
|
// loop's rules trust. "email:" is not a source and neither is "email:anything
|
|
// he could post at the socket".
|
|
func TestIngestRejectsBadMailbox(t *testing.T) {
|
|
for _, name := range []string{"", " ", "IN BOX", "IN\nBOX", "IN\x00BOX", strings.Repeat("щ", maxMailboxChars+1)} {
|
|
mi, st, fake := newTestIntake(t, `[{"text":"дело","due":""}]`)
|
|
req := ingestReq()
|
|
req.Mailbox = name
|
|
if _, err := mi.ingest(context.Background(), req); err == nil {
|
|
t.Errorf("mailbox %q was accepted", name)
|
|
}
|
|
if fake.calls != 0 {
|
|
t.Errorf("mailbox %q reached the model", name)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 0 {
|
|
t.Errorf("mailbox %q wrote %d tasks", name, len(tasks))
|
|
}
|
|
}
|
|
}
|
|
|
|
// slowLLM burns most of the extraction budget before answering, the way a
|
|
// Thinking 1.7B does on a long mail.
|
|
type slowLLM struct {
|
|
reply string
|
|
delay time.Duration
|
|
}
|
|
|
|
func (s *slowLLM) Complete(ctx context.Context, _ llm.Req) (string, error) {
|
|
select {
|
|
case <-time.After(s.delay):
|
|
return s.reply, nil
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
}
|
|
|
|
// The extraction budget must not also bound the writes. It used to be one
|
|
// context, so a model answering near the deadline lost the candidates it had
|
|
// just produced.
|
|
func TestIngestCapturesAfterASlowExtraction(t *testing.T) {
|
|
st := newTestStore(t)
|
|
mi := &mailIntake{
|
|
st: st,
|
|
ex: email.NewExtractor(&slowLLM{reply: `[{"text":"оплатить счёт","due":""}]`, delay: 90 * time.Millisecond}, 0, nil),
|
|
timeout: 100 * time.Millisecond,
|
|
now: func() time.Time { return time.Date(2026, 8, 1, 10, 0, 0, 0, time.UTC) },
|
|
}
|
|
resp, err := mi.ingest(context.Background(), ingestReq())
|
|
if err != nil {
|
|
t.Fatalf("ingest: %v", err)
|
|
}
|
|
if resp.Created != 1 {
|
|
t.Fatalf("resp = %+v, want the candidate captured", resp)
|
|
}
|
|
if tasks, _ := st.ListTasks(context.Background(), ""); len(tasks) != 1 {
|
|
t.Errorf("got %d tasks, want 1", len(tasks))
|
|
}
|
|
}
|