f99010fae1
Desktop Artifacts / Desktop Build (Linux) (push) Waiting to run
Desktop Artifacts / Desktop Build (Windows) (push) Waiting to run
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Waiting to run
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Waiting to run
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
1037 lines
37 KiB
Go
1037 lines
37 KiB
Go
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/spf13/cobra"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"go.kenn.io/kit/daemon"
|
|
kitvec "go.kenn.io/kit/vector"
|
|
"go.kenn.io/kit/vector/sqlitevec"
|
|
|
|
"go.kenn.io/agentsview/internal/config"
|
|
"go.kenn.io/agentsview/internal/db"
|
|
"go.kenn.io/agentsview/internal/dbtest"
|
|
"go.kenn.io/agentsview/internal/vector"
|
|
)
|
|
|
|
// writeEmbeddingsTestConfig writes a config.toml under dataDir with [vector]
|
|
// enabled and pointed at endpoint, using small-but-valid operational values
|
|
// so config.Validate accepts it.
|
|
func writeEmbeddingsTestConfig(t *testing.T, dataDir, endpoint string) {
|
|
t.Helper()
|
|
writeTestConfig(t, dataDir, fmt.Sprintf(`
|
|
[vector]
|
|
enabled = true
|
|
|
|
[vector.embeddings]
|
|
model = "test-model"
|
|
dimension = 3
|
|
max_input_chars = 1000
|
|
|
|
[vector.embeddings.servers.local]
|
|
endpoint = %q
|
|
batch_size = 10
|
|
timeout = "5s"
|
|
max_retries = 1
|
|
`, endpoint))
|
|
}
|
|
|
|
// newEmbeddingsStubServer returns an httptest server that answers the
|
|
// OpenAI-compatible /embeddings endpoint with dimension-length vectors for
|
|
// every input, mirroring the shape internal/vector/encoder_test.go's stub
|
|
// uses.
|
|
func newEmbeddingsStubServer(t *testing.T, dimension int) *httptest.Server {
|
|
t.Helper()
|
|
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Model string `json:"model"`
|
|
Input []string `json:"input"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
|
|
data := make([]map[string]any, len(req.Input))
|
|
for i := range req.Input {
|
|
vec := make([]float32, dimension)
|
|
for j := range vec {
|
|
vec[j] = float32(i + 1)
|
|
}
|
|
data[i] = map[string]any{"index": i, "embedding": vec}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": data}))
|
|
}))
|
|
}
|
|
|
|
// seedEmbeddableArchive creates sessions.db at cfg's default path with one
|
|
// session carrying one user and one assistant message, both eligible for
|
|
// embedding.
|
|
func seedEmbeddableArchive(t *testing.T, dataDir string) {
|
|
t.Helper()
|
|
d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
|
|
// Three messages but only TWO embeddable units: the user message plus
|
|
// the contiguous assistant run [1,2]. Tests asserting document counts
|
|
// against this seed are therefore discriminating between unit-grouped
|
|
// and per-message counting.
|
|
dbtest.SeedSessionWithMessages(t, d, "sess-1", "proj", []db.Message{
|
|
dbtest.UserMsg("sess-1", 0, "hello there"),
|
|
dbtest.AsstMsg("sess-1", 1, "hi back"),
|
|
dbtest.AsstMsg("sess-1", 2, "and a follow-up thought"),
|
|
})
|
|
}
|
|
|
|
// seedEmbeddableArchiveWithAutomated seeds the same normal session
|
|
// seedEmbeddableArchive does, plus one automated session with one
|
|
// embeddable message, for the include-automated scope tests.
|
|
func seedEmbeddableArchiveWithAutomated(t *testing.T, dataDir string) {
|
|
t.Helper()
|
|
d := dbtest.OpenTestDBAt(t, filepath.Join(dataDir, "sessions.db"))
|
|
dbtest.SeedSessionWithMessages(t, d, "sess-1", "proj", []db.Message{
|
|
dbtest.UserMsg("sess-1", 0, "hello there"),
|
|
dbtest.AsstMsg("sess-1", 1, "hi back"),
|
|
dbtest.AsstMsg("sess-1", 2, "and a follow-up thought"),
|
|
})
|
|
dbtest.SeedSessionWithMessages(t, d, "sess-auto", "proj", []db.Message{
|
|
dbtest.UserMsg("sess-auto", 0, "roborev output"),
|
|
}, func(s *db.Session) { s.IsAutomated = true })
|
|
}
|
|
|
|
// TestVectorGenerationParams asserts vectorGeneration's Params map carries
|
|
// exactly the three run_v1 fingerprint keys the plan requires, with
|
|
// chunk_overlap_chars derived from vector.ChunkOverlap so a future change to
|
|
// that formula cannot silently drift from the fingerprint. An empty
|
|
// input_suffix must be absent from the map (not present as ""), so configs
|
|
// written before the key existed keep their fingerprints; a non-empty suffix
|
|
// joins the fingerprint and cuts a new generation.
|
|
func TestVectorGenerationParams(t *testing.T) {
|
|
c := config.VectorEmbeddingsConfig{
|
|
Model: "test-model",
|
|
Dimension: 3,
|
|
MaxInputChars: 4000,
|
|
}
|
|
|
|
gen := vectorGeneration(c)
|
|
|
|
assert.Equal(t, "test-model", gen.Model)
|
|
assert.Equal(t, 3, gen.Dimensions)
|
|
assert.Equal(t, map[string]string{
|
|
"max_input_chars": "4000",
|
|
"doc_unit_scheme": "run_v1",
|
|
"chunk_overlap_chars": strconv.Itoa(vector.ChunkOverlap(4000)),
|
|
}, gen.Params)
|
|
|
|
c.InputSuffix = "<|endoftext|>"
|
|
gen = vectorGeneration(c)
|
|
assert.Equal(t, map[string]string{
|
|
"max_input_chars": "4000",
|
|
"doc_unit_scheme": "run_v1",
|
|
"chunk_overlap_chars": strconv.Itoa(vector.ChunkOverlap(4000)),
|
|
"input_suffix": "<|endoftext|>",
|
|
}, gen.Params)
|
|
|
|
// request_dimensions follows the same only-when-set rule, and enabling it
|
|
// must change the fingerprint even at an unchanged dimension value, so
|
|
// the staleness gate forces a rebuild before reduced query vectors are
|
|
// compared against native-dimension stored vectors.
|
|
nativeFingerprint := gen.Fingerprint()
|
|
c.RequestDimensions = true
|
|
gen = vectorGeneration(c)
|
|
assert.Equal(t, map[string]string{
|
|
"max_input_chars": "4000",
|
|
"doc_unit_scheme": "run_v1",
|
|
"chunk_overlap_chars": strconv.Itoa(vector.ChunkOverlap(4000)),
|
|
"input_suffix": "<|endoftext|>",
|
|
"request_dimensions": "true",
|
|
}, gen.Params)
|
|
assert.NotEqual(t, nativeFingerprint, gen.Fingerprint(),
|
|
"enabling request_dimensions cuts a new generation")
|
|
}
|
|
|
|
// TestEmbeddingsDisabledReturnsError asserts every subcommand refuses with
|
|
// the exact "vector search is not enabled" message when [vector] is off
|
|
// (the default), before attempting any daemon detection or I/O.
|
|
func TestEmbeddingsDisabledReturnsError(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
newCmd func() *cobra.Command
|
|
args []string
|
|
}{
|
|
{"build", newEmbeddingsBuildCommand, nil},
|
|
{"list", newEmbeddingsListCommand, nil},
|
|
{"activate", newEmbeddingsActivateCommand, []string{"1"}},
|
|
{"retire", newEmbeddingsRetireCommand, []string{"1"}},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
testDataDir(t)
|
|
|
|
cmd := tt.newCmd()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(tt.args)
|
|
|
|
err := cmd.Execute()
|
|
require.Error(t, err)
|
|
assert.Equal(t,
|
|
"vector search is not enabled: set [vector] enabled = true in config.toml",
|
|
err.Error())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestEmbeddingsListRendersTable seeds vectors.db directly with one
|
|
// generation (bypassing a full build) and asserts `embeddings list`
|
|
// renders it as a table with the documented columns and a
|
|
// fingerprint truncated to 12 characters.
|
|
func TestEmbeddingsListRendersTable(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
cfg, err := config.LoadMinimal()
|
|
require.NoError(t, err)
|
|
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
fp, err := ix.EnsureGeneration(ctx, vectorGeneration(cfg.Vector.Embeddings), sqlitevec.StateActive)
|
|
require.NoError(t, err)
|
|
require.NoError(t, ix.Close())
|
|
|
|
cmd := newEmbeddingsListCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
require.Len(t, lines, 2, "expected a header line and one generation row, got: %q", out.String())
|
|
assert.Contains(t, lines[0], "STORE")
|
|
assert.Contains(t, lines[0], "ID")
|
|
assert.Contains(t, lines[0], "STATE")
|
|
assert.Contains(t, lines[0], "MODEL")
|
|
assert.Contains(t, lines[0], "DIM")
|
|
assert.Contains(t, lines[0], "EMBEDDED")
|
|
assert.Contains(t, lines[0], "MISSING")
|
|
assert.Contains(t, lines[0], "FINGERPRINT")
|
|
|
|
assert.Contains(t, lines[1], "messages")
|
|
assert.Contains(t, lines[1], "active")
|
|
assert.Contains(t, lines[1], "test-model")
|
|
assert.Contains(t, lines[1], truncateFingerprint(fp))
|
|
assert.NotContains(t, lines[1], fp, "fingerprint column must be truncated to 12 chars")
|
|
}
|
|
|
|
// TestEmbeddingsUnknownStoreFails asserts an unrecognized --store value
|
|
// fails fast with the known-stores list, before any config-driven daemon
|
|
// detection or vectors.db access.
|
|
func TestEmbeddingsUnknownStoreFails(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
err := runEmbeddingsList(context.Background(), io.Discard, false, "bogus")
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), `unknown embedding store "bogus"`)
|
|
assert.Contains(t, err.Error(), "messages")
|
|
}
|
|
|
|
// TestEmbeddingsListShowsStoreColumn asserts `embeddings list --store
|
|
// messages` renders the STORE column stamped with the resolved store name.
|
|
func TestEmbeddingsListShowsStoreColumn(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
cfg, err := config.LoadMinimal()
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
_, err = ix.EnsureGeneration(ctx, vectorGeneration(cfg.Vector.Embeddings), sqlitevec.StateActive)
|
|
require.NoError(t, err)
|
|
require.NoError(t, ix.Close())
|
|
|
|
var buf bytes.Buffer
|
|
require.NoError(t, runEmbeddingsList(context.Background(), &buf, false, "messages"))
|
|
assert.Contains(t, buf.String(), "STORE")
|
|
assert.Contains(t, buf.String(), "messages")
|
|
}
|
|
|
|
// TestEmbeddingsListJSONFormat asserts `embeddings list --format json`
|
|
// wraps the generation list in the same {"generations": [...]} shape the
|
|
// Task 14 HTTP endpoint uses.
|
|
func TestEmbeddingsListJSONFormat(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
cfg, err := config.LoadMinimal()
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
_, err = ix.EnsureGeneration(ctx, vectorGeneration(cfg.Vector.Embeddings), sqlitevec.StateActive)
|
|
require.NoError(t, err)
|
|
require.NoError(t, ix.Close())
|
|
|
|
cmd := newEmbeddingsListCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--format", "json"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
var body struct {
|
|
Generations []vector.GenerationInfo `json:"generations"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &body))
|
|
require.Len(t, body.Generations, 1)
|
|
assert.Equal(t, "active", body.Generations[0].State)
|
|
assert.Equal(t, "messages", body.Generations[0].Store)
|
|
}
|
|
|
|
// TestEmbeddingsListEmptyIndexReturnsNoRows asserts `embeddings list`
|
|
// against a data dir where vectors.db was never built prints only the
|
|
// header, without erroring.
|
|
func TestEmbeddingsListEmptyIndexReturnsNoRows(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
cmd := newEmbeddingsListCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
lines := strings.Split(strings.TrimSpace(out.String()), "\n")
|
|
require.Len(t, lines, 1, "expected only the header line, got: %q", out.String())
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectEndToEnd seeds a temp archive with one user
|
|
// message and a two-message assistant run (two embeddable units), points
|
|
// [vector.embeddings] at an httptest OpenAI-compatible stub, and asserts the
|
|
// direct build path embeds both units and prints the exact final summary and
|
|
// activation line.
|
|
func TestEmbeddingsBuildDirectEndToEnd(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "Embedded 2 documents (2 chunks), skipped 0, stale 0")
|
|
assert.Contains(t, out.String(), "Generation activated.")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectPrintsProgress shrinks the direct path's
|
|
// progress ticker and slows the embeddings stub down so the build is still
|
|
// running when the ticker fires, asserting at least one progress line in
|
|
// the documented format is printed before the final summary.
|
|
func TestEmbeddingsBuildDirectPrintsProgress(t *testing.T) {
|
|
orig := directBuildProgressInterval
|
|
directBuildProgressInterval = 5 * time.Millisecond
|
|
t.Cleanup(func() { directBuildProgressInterval = orig })
|
|
|
|
dataDir := testDataDir(t)
|
|
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
var req struct {
|
|
Input []string `json:"input"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
time.Sleep(150 * time.Millisecond)
|
|
|
|
data := make([]map[string]any, len(req.Input))
|
|
for i := range req.Input {
|
|
data[i] = map[string]any{"index": i, "embedding": []float32{1, 2, 3}}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
require.NoError(t, json.NewEncoder(w).Encode(map[string]any{"data": data}))
|
|
}))
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
// The scan-phase line is the deterministic progress signal here: the
|
|
// build reports "scanning" until the stub's first (deliberately slow)
|
|
// encode call reports chunk counters, and the poll ticker fires many
|
|
// times inside that window. A chunk-counter line may or may not appear
|
|
// depending on how quickly the build finishes after that first report.
|
|
assert.Contains(t, out.String(),
|
|
"progress: scanning archive for changed documents...",
|
|
"a running build must print at least one progress line")
|
|
assert.Contains(t, out.String(), "Embedded 2 documents (2 chunks), skipped 0, stale 0")
|
|
}
|
|
|
|
// TestPrintBuildProgressPhases pins per-phase progress rendering: the scan
|
|
// phase (and an empty phase from a daemon predating the scanning report)
|
|
// prints the scan line instead of a misleading "0/0 chunks", while the
|
|
// embedding phase prints chunk counters.
|
|
func TestPrintBuildProgressPhases(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
phase string
|
|
done int64
|
|
total int64
|
|
want string
|
|
}{
|
|
{
|
|
name: "scanning phase", phase: "scanning",
|
|
want: "progress: scanning archive for changed documents...\n",
|
|
},
|
|
{
|
|
name: "empty phase without totals treated as scanning", phase: "",
|
|
want: "progress: scanning archive for changed documents...\n",
|
|
},
|
|
{
|
|
name: "embedding phase with zero chunks", phase: "embedding",
|
|
want: "progress: 0/0 chunks (0.0%)\n",
|
|
},
|
|
{
|
|
name: "embedding phase with counters", phase: "embedding",
|
|
done: 587, total: 6004,
|
|
want: "progress: 587/6004 chunks (9.8%)\n",
|
|
},
|
|
{
|
|
name: "totals without phase still print counters", phase: "",
|
|
done: 1, total: 4,
|
|
want: "progress: 1/4 chunks (25.0%)\n",
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
printBuildProgress(&out, tt.phase, tt.done, tt.total)
|
|
assert.Equal(t, tt.want, out.String())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildProgressPrinterDeduplicates pins that unchanged status between
|
|
// polls prints nothing: a long scan renders one scan line, not one per poll,
|
|
// and a stalled chunk counter is not repeated.
|
|
func TestBuildProgressPrinterDeduplicates(t *testing.T) {
|
|
var out bytes.Buffer
|
|
var p buildProgressPrinter
|
|
p.print(&out, "scanning", 0, 0)
|
|
p.print(&out, "scanning", 0, 0)
|
|
p.print(&out, "scanning", 0, 0)
|
|
p.print(&out, "embedding", 10, 40)
|
|
p.print(&out, "embedding", 10, 40)
|
|
p.print(&out, "embedding", 17, 40)
|
|
assert.Equal(t,
|
|
"progress: scanning archive for changed documents...\n"+
|
|
"progress: 10/40 chunks (25.0%)\n"+
|
|
"progress: 17/40 chunks (42.5%)\n",
|
|
out.String())
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectConcurrentFlockFails asserts a second direct
|
|
// build invocation, run while another process (simulated by acquiring the
|
|
// lock in the test) holds vectors.write.lock, fails immediately with the
|
|
// write-lock-held error rather than racing the first build.
|
|
func TestEmbeddingsBuildDirectConcurrentFlockFails(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
held, err := tryAcquireNamedLock(dataDir, vectorsWriteLockFile)
|
|
require.NoError(t, err)
|
|
defer held.Close()
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
|
|
err = cmd.Execute()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "held by another process")
|
|
assert.Contains(t, err.Error(), vectorsWriteLockFile)
|
|
}
|
|
|
|
// TestEmbeddingsBuildFullRebuildPromptsAndAborts asserts --full-rebuild
|
|
// without --yes prints the exact confirmation prompt with the true
|
|
// embeddable-unit count (the seeded user message and the assistant run are
|
|
// one document each), and a "no" answer aborts without building.
|
|
func TestEmbeddingsBuildFullRebuildPromptsAndAborts(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetIn(strings.NewReader("n\n"))
|
|
cmd.SetArgs([]string{"--full-rebuild"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "This re-embeds all 2 documents. Continue?")
|
|
assert.Contains(t, out.String(), "Aborted.")
|
|
assert.NotContains(t, out.String(), "Embedded")
|
|
}
|
|
|
|
// TestEmbeddingsBuildFullRebuildYesSkipsPrompt asserts --yes skips the
|
|
// confirmation prompt entirely and proceeds straight to the build.
|
|
func TestEmbeddingsBuildFullRebuildYesSkipsPrompt(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--full-rebuild", "--yes"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.NotContains(t, out.String(), "Continue?")
|
|
assert.Contains(t, out.String(), "Embedded 2 documents (2 chunks), skipped 0, stale 0")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectExcludesAutomatedByDefault asserts the direct
|
|
// build path's default scope (no config include_automated, no
|
|
// --include-automated flag) never embeds an automated session's messages.
|
|
func TestEmbeddingsBuildDirectExcludesAutomatedByDefault(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchiveWithAutomated(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "Embedded 2 documents (2 chunks), skipped 0, stale 0",
|
|
"the automated session's message must not be embedded by default")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectIncludeAutomatedFlagOverridesConfig asserts
|
|
// --include-automated forces the automated session's message into the build
|
|
// even though [vector].include_automated defaults to false.
|
|
func TestEmbeddingsBuildDirectIncludeAutomatedFlagOverridesConfig(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchiveWithAutomated(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--include-automated"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "Embedded 3 documents (3 chunks), skipped 0, stale 0",
|
|
"--include-automated must embed the automated session's message too")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectIncludeAutomatedConfigDefault asserts
|
|
// [vector].include_automated = true embeds automated sessions without
|
|
// needing the --include-automated flag on every build.
|
|
func TestEmbeddingsBuildDirectIncludeAutomatedConfigDefault(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeTestConfig(t, dataDir, fmt.Sprintf(`
|
|
[vector]
|
|
enabled = true
|
|
include_automated = true
|
|
|
|
[vector.embeddings]
|
|
model = "test-model"
|
|
dimension = 3
|
|
max_input_chars = 1000
|
|
|
|
[vector.embeddings.servers.local]
|
|
endpoint = %q
|
|
batch_size = 10
|
|
timeout = "5s"
|
|
max_retries = 1
|
|
`, stub.URL+"/v1"))
|
|
seedEmbeddableArchiveWithAutomated(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "Embedded 3 documents (3 chunks), skipped 0, stale 0")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDirectIncludeAutomatedFlagFalseOverridesConfigTrue
|
|
// asserts --include-automated=false narrows the scope back down for this one
|
|
// build even when [vector].include_automated defaults to true: the parsed
|
|
// flag value must win, not just "the flag was passed forces true".
|
|
func TestEmbeddingsBuildDirectIncludeAutomatedFlagFalseOverridesConfigTrue(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeTestConfig(t, dataDir, fmt.Sprintf(`
|
|
[vector]
|
|
enabled = true
|
|
include_automated = true
|
|
|
|
[vector.embeddings]
|
|
model = "test-model"
|
|
dimension = 3
|
|
max_input_chars = 1000
|
|
|
|
[vector.embeddings.servers.local]
|
|
endpoint = %q
|
|
batch_size = 10
|
|
timeout = "5s"
|
|
max_retries = 1
|
|
`, stub.URL+"/v1"))
|
|
seedEmbeddableArchiveWithAutomated(t, dataDir)
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--include-automated=false"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.Contains(t, out.String(), "Embedded 2 documents (2 chunks), skipped 0, stale 0",
|
|
"--include-automated=false must exclude the automated session's message "+
|
|
"even though the config default is true")
|
|
}
|
|
|
|
// TestEmbeddingsBuildIncludeAutomatedFlagThreadsToDaemonRequest drives the
|
|
// daemon build path and asserts --include-automated forces
|
|
// BuildRequest.IncludeAutomated to true in the request body, overriding the
|
|
// (default false) config value for this one build.
|
|
func TestEmbeddingsBuildIncludeAutomatedFlagThreadsToDaemonRequest(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
var gotIncludeAutomated atomic.Bool
|
|
startEmbeddingsTestDaemon(t, dataDir, map[string]http.HandlerFunc{
|
|
"POST /api/v1/embeddings/build": func(w http.ResponseWriter, r *http.Request) {
|
|
var req vector.BuildRequest
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
gotIncludeAutomated.Store(req.IncludeAutomated)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"started": true})
|
|
},
|
|
"GET /api/v1/embeddings/status": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(vector.BuildStatus{Running: false})
|
|
},
|
|
})
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--include-automated"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.True(t, gotIncludeAutomated.Load(), "--include-automated must pass through to the daemon")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDaemonRequestDefaultsToConfigScope asserts that without
|
|
// --include-automated, the daemon request body still carries the resolved
|
|
// config value explicitly (true here), rather than silently falling back to
|
|
// the zero value the daemon's own (possibly different) config would use.
|
|
func TestEmbeddingsBuildDaemonRequestDefaultsToConfigScope(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeTestConfig(t, dataDir, `
|
|
[vector]
|
|
enabled = true
|
|
include_automated = true
|
|
|
|
[vector.embeddings]
|
|
model = "test-model"
|
|
dimension = 3
|
|
max_input_chars = 1000
|
|
|
|
[vector.embeddings.servers.local]
|
|
endpoint = "http://127.0.0.1:1"
|
|
batch_size = 10
|
|
timeout = "5s"
|
|
max_retries = 1
|
|
`)
|
|
|
|
var gotIncludeAutomated atomic.Bool
|
|
startEmbeddingsTestDaemon(t, dataDir, map[string]http.HandlerFunc{
|
|
"POST /api/v1/embeddings/build": func(w http.ResponseWriter, r *http.Request) {
|
|
var req vector.BuildRequest
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
gotIncludeAutomated.Store(req.IncludeAutomated)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"started": true})
|
|
},
|
|
"GET /api/v1/embeddings/status": func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(vector.BuildStatus{Running: false})
|
|
},
|
|
})
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.True(t, gotIncludeAutomated.Load(),
|
|
"the CLI must resolve its local config's include_automated=true and send it explicitly")
|
|
}
|
|
|
|
// TestEmbeddingsRetireDirectRoundTrip builds an active generation directly,
|
|
// asserts retiring it without --force is refused with the manager's exact
|
|
// message, and that --force performs the retirement and prints the success
|
|
// line.
|
|
func TestEmbeddingsRetireDirectRoundTrip(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
cfg, err := config.LoadMinimal()
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
_, err = ix.EnsureGeneration(ctx, vectorGeneration(cfg.Vector.Embeddings), sqlitevec.StateActive)
|
|
require.NoError(t, err)
|
|
require.NoError(t, ix.Close())
|
|
|
|
retireCmd := newEmbeddingsRetireCommand()
|
|
var out bytes.Buffer
|
|
retireCmd.SetOut(&out)
|
|
retireCmd.SetArgs([]string{"1"})
|
|
err = retireCmd.Execute()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "is active")
|
|
assert.Contains(t, err.Error(), "use --force")
|
|
|
|
forceCmd := newEmbeddingsRetireCommand()
|
|
var forceOut bytes.Buffer
|
|
forceCmd.SetOut(&forceOut)
|
|
forceCmd.SetArgs([]string{"1", "--force"})
|
|
require.NoError(t, forceCmd.Execute())
|
|
assert.Equal(t, "Generation 1 retired.\n", forceOut.String())
|
|
}
|
|
|
|
// TestEmbeddingsActivateDirectRefusalThenForce builds generation 1
|
|
// end-to-end (active, 2 docs embedded), registers a second never-filled
|
|
// building generation with Missing = 2, and asserts the direct path
|
|
// surfaces Manager.Activate's refusal wording verbatim without --force,
|
|
// then succeeds with --force.
|
|
func TestEmbeddingsActivateDirectRefusalThenForce(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
stub := newEmbeddingsStubServer(t, 3)
|
|
defer stub.Close()
|
|
writeEmbeddingsTestConfig(t, dataDir, stub.URL+"/v1")
|
|
seedEmbeddableArchive(t, dataDir)
|
|
|
|
buildCmd := newEmbeddingsBuildCommand()
|
|
var buildOut bytes.Buffer
|
|
buildCmd.SetOut(&buildOut)
|
|
buildCmd.SetArgs(nil)
|
|
require.NoError(t, buildCmd.Execute())
|
|
|
|
cfg, err := config.LoadMinimal()
|
|
require.NoError(t, err)
|
|
ctx := context.Background()
|
|
ix, err := vector.Open(ctx, cfg.Vector.ResolvedDBPath(cfg.DataDir), false,
|
|
cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
otherGen := kitvec.Generation{Model: "other-model", Dimensions: 3}
|
|
_, err = ix.EnsureGeneration(ctx, otherGen, sqlitevec.StateBuilding)
|
|
require.NoError(t, err)
|
|
require.NoError(t, ix.Close())
|
|
|
|
activateCmd := newEmbeddingsActivateCommand()
|
|
var out bytes.Buffer
|
|
activateCmd.SetOut(&out)
|
|
activateCmd.SetArgs([]string{"2"})
|
|
err = activateCmd.Execute()
|
|
require.Error(t, err)
|
|
assert.Equal(t,
|
|
"generation 2 still has 2 documents needing embedding; use --force",
|
|
err.Error(), "the manager's refusal message must surface verbatim")
|
|
|
|
forceCmd := newEmbeddingsActivateCommand()
|
|
var forceOut bytes.Buffer
|
|
forceCmd.SetOut(&forceOut)
|
|
forceCmd.SetArgs([]string{"2", "--force"})
|
|
require.NoError(t, forceCmd.Execute())
|
|
assert.Equal(t, "Generation 2 activated.\n", forceOut.String())
|
|
}
|
|
|
|
// TestEmbeddingsActivateInvalidIDReturnsError asserts a non-numeric
|
|
// argument is rejected before any config or I/O work happens.
|
|
func TestEmbeddingsActivateInvalidIDReturnsError(t *testing.T) {
|
|
testDataDir(t)
|
|
cmd := newEmbeddingsActivateCommand()
|
|
cmd.SetArgs([]string{"not-a-number"})
|
|
err := cmd.Execute()
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "invalid generation id")
|
|
}
|
|
|
|
// startEmbeddingsTestDaemon starts an httptest server that answers the kit
|
|
// daemon ping (so FindDaemonRuntime's probe succeeds) plus the given
|
|
// embeddings endpoint handlers, writes a live writable runtime record for
|
|
// it into dataDir (so IsLocalDaemonActive reports true), and registers
|
|
// cleanup. Handlers are keyed by "METHOD /path".
|
|
func startEmbeddingsTestDaemon(
|
|
t *testing.T, dataDir string, handlers map[string]http.HandlerFunc,
|
|
) {
|
|
t.Helper()
|
|
mux := http.NewServeMux()
|
|
mux.Handle("GET /api/ping", daemon.NewPingHandler(daemon.PingHandlerOptions{
|
|
Service: daemonService,
|
|
Version: "test",
|
|
}))
|
|
for pattern, h := range handlers {
|
|
mux.HandleFunc(pattern, h)
|
|
}
|
|
srv := httptest.NewServer(mux)
|
|
t.Cleanup(srv.Close)
|
|
|
|
endpoint := serverEndpoint(t, srv)
|
|
writeDaemonRuntimeForTest(t, dataDir, endpoint.Host, endpoint.Port, "test", false)
|
|
}
|
|
|
|
// TestEmbeddingsListDispatchesToDaemon drives the real `embeddings list`
|
|
// command with a live writable daemon runtime record present, asserting
|
|
// the command routes through the daemon's /generations endpoint (never
|
|
// touching vectors.db, which does not exist) and renders its response.
|
|
func TestEmbeddingsListDispatchesToDaemon(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
var listCalled atomic.Bool
|
|
startEmbeddingsTestDaemon(t, dataDir, map[string]http.HandlerFunc{
|
|
"GET /api/v1/embeddings/generations": func(w http.ResponseWriter, r *http.Request) {
|
|
listCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"generations": []vector.GenerationInfo{{
|
|
ID: 1, State: "active", Model: "daemon-model", Dimension: 3,
|
|
Fingerprint: "abcdef0123456789", Embedded: 7,
|
|
}},
|
|
})
|
|
},
|
|
})
|
|
|
|
cmd := newEmbeddingsListCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs(nil)
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.True(t, listCalled.Load(), "list must route through the daemon endpoint")
|
|
assert.Contains(t, out.String(), "daemon-model")
|
|
assert.Contains(t, out.String(), "abcdef012345")
|
|
assert.NoFileExists(t, filepath.Join(dataDir, "vectors.db"),
|
|
"daemon-dispatched list must not open vectors.db directly")
|
|
}
|
|
|
|
// TestEmbeddingsBuildDispatchesToDaemon drives the real `embeddings build`
|
|
// command with a live writable daemon runtime record present: the daemon
|
|
// accepts the build (202) and immediately reports a completed run, so the
|
|
// poll terminates after one status call and prints the final summary.
|
|
func TestEmbeddingsBuildDispatchesToDaemon(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
var buildCalled, statusCalled atomic.Bool
|
|
startEmbeddingsTestDaemon(t, dataDir, map[string]http.HandlerFunc{
|
|
"POST /api/v1/embeddings/build": func(w http.ResponseWriter, r *http.Request) {
|
|
buildCalled.Store(true)
|
|
var req vector.BuildRequest
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
|
assert.True(t, req.Backstop, "--backstop must pass through to the daemon")
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusAccepted)
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"started": true})
|
|
},
|
|
"GET /api/v1/embeddings/status": func(w http.ResponseWriter, r *http.Request) {
|
|
statusCalled.Store(true)
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(vector.BuildStatus{
|
|
Running: false,
|
|
LastResult: &vector.BuildResult{
|
|
Activated: true,
|
|
Fill: kitvec.FillStats{Documents: 5, Chunks: 6},
|
|
},
|
|
})
|
|
},
|
|
})
|
|
|
|
cmd := newEmbeddingsBuildCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"--backstop"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.True(t, buildCalled.Load(), "build must route through the daemon endpoint")
|
|
assert.True(t, statusCalled.Load(), "build must poll the daemon status endpoint")
|
|
assert.Contains(t, out.String(), "Embedded 5 documents (6 chunks), skipped 0, stale 0")
|
|
assert.Contains(t, out.String(), "Generation activated.")
|
|
assert.NoFileExists(t, filepath.Join(dataDir, "vectors.db"),
|
|
"daemon-dispatched build must not open vectors.db directly")
|
|
}
|
|
|
|
// TestEmbeddingsActivateDispatchesToDaemon drives the real `embeddings
|
|
// activate` command with a live writable daemon runtime record present,
|
|
// asserting the id and --force flag pass through to the daemon endpoint.
|
|
func TestEmbeddingsActivateDispatchesToDaemon(t *testing.T) {
|
|
dataDir := testDataDir(t)
|
|
writeEmbeddingsTestConfig(t, dataDir, "http://127.0.0.1:1")
|
|
|
|
var gotForce atomic.Bool
|
|
startEmbeddingsTestDaemon(t, dataDir, map[string]http.HandlerFunc{
|
|
"POST /api/v1/embeddings/generations/3/activate": func(w http.ResponseWriter, r *http.Request) {
|
|
var body struct {
|
|
Force bool `json:"force"`
|
|
}
|
|
require.NoError(t, json.NewDecoder(r.Body).Decode(&body))
|
|
gotForce.Store(body.Force)
|
|
w.WriteHeader(http.StatusNoContent)
|
|
},
|
|
})
|
|
|
|
cmd := newEmbeddingsActivateCommand()
|
|
var out bytes.Buffer
|
|
cmd.SetOut(&out)
|
|
cmd.SetArgs([]string{"3", "--force"})
|
|
require.NoError(t, cmd.Execute())
|
|
|
|
assert.True(t, gotForce.Load(), "--force must pass through to the daemon")
|
|
assert.Equal(t, "Generation 3 activated.\n", out.String())
|
|
}
|
|
|
|
// TestBuildViaDaemonConflictThenPolls drives buildViaDaemon (the daemon
|
|
// build path's core logic) against a fake HTTP server that refuses the
|
|
// first build attempt with 409 and reports Running until the second status
|
|
// poll, asserting the CLI prints the "already running" notice and then the
|
|
// same final summary format the direct path uses.
|
|
func TestBuildViaDaemonConflictThenPolls(t *testing.T) {
|
|
orig := embeddingsPollInterval
|
|
embeddingsPollInterval = time.Millisecond
|
|
t.Cleanup(func() { embeddingsPollInterval = orig })
|
|
|
|
var statusCalls atomic.Int32
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/embeddings/build", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(http.StatusConflict)
|
|
_ = json.NewEncoder(w).Encode(map[string]string{
|
|
"error": "an embeddings build is already running",
|
|
})
|
|
})
|
|
mux.HandleFunc("/api/v1/embeddings/status", func(w http.ResponseWriter, r *http.Request) {
|
|
n := statusCalls.Add(1)
|
|
status := vector.BuildStatus{Running: n < 2}
|
|
if n >= 2 {
|
|
status.LastResult = &vector.BuildResult{
|
|
Activated: true,
|
|
Fill: kitvec.FillStats{Documents: 3, Chunks: 3},
|
|
}
|
|
}
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(status)
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := embeddingsDaemonClient{baseURL: srv.URL}
|
|
var out bytes.Buffer
|
|
err := buildViaDaemon(context.Background(), &out, client, vector.BuildRequest{})
|
|
require.NoError(t, err)
|
|
|
|
assert.Contains(t, out.String(), "a build is already running (daemon)")
|
|
assert.Contains(t, out.String(), "Embedded 3 documents (3 chunks), skipped 0, stale 0")
|
|
assert.Contains(t, out.String(), "Generation activated.")
|
|
assert.GreaterOrEqual(t, statusCalls.Load(), int32(2))
|
|
}
|
|
|
|
// TestBuildViaDaemonLastErrorReturnsNonZero asserts a stopped build with a
|
|
// non-empty LastError becomes the returned error, so the CLI exits
|
|
// non-zero, matching the direct path's error propagation.
|
|
func TestBuildViaDaemonLastErrorReturnsNonZero(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
mux.HandleFunc("/api/v1/embeddings/build", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]bool{"started": true})
|
|
})
|
|
mux.HandleFunc("/api/v1/embeddings/status", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(vector.BuildStatus{
|
|
Running: false,
|
|
LastError: "encoder rejected input",
|
|
})
|
|
})
|
|
srv := httptest.NewServer(mux)
|
|
defer srv.Close()
|
|
|
|
client := embeddingsDaemonClient{baseURL: srv.URL}
|
|
var out bytes.Buffer
|
|
err := buildViaDaemon(context.Background(), &out, client, vector.BuildRequest{})
|
|
require.Error(t, err)
|
|
assert.Equal(t, "encoder rejected input", err.Error())
|
|
}
|
|
|
|
// TestDirectListGenerationsVersionMismatchSurfacesRebuildRequired pins the
|
|
// `embeddings list` direct path's version gate: against a vectors.db written
|
|
// by an older mirror schema version it must surface the rebuild-required
|
|
// error instead of listing stale-shape generation data.
|
|
func TestDirectListGenerationsVersionMismatchSurfacesRebuildRequired(t *testing.T) {
|
|
dataDir := t.TempDir()
|
|
cfg := vectorTestConfig(dataDir)
|
|
path := cfg.Vector.ResolvedDBPath(dataDir)
|
|
|
|
seed, err := vector.Open(context.Background(), path, false, cfg.Vector.Embeddings.MaxInputChars)
|
|
require.NoError(t, err)
|
|
require.NoError(t, seed.Close())
|
|
raw, err := sql.Open("sqlite3", path)
|
|
require.NoError(t, err)
|
|
_, err = raw.Exec(`UPDATE vector_meta SET value = '2' WHERE key = 'mirror_schema_version'`)
|
|
require.NoError(t, err)
|
|
require.NoError(t, raw.Close())
|
|
|
|
_, err = directListGenerations(context.Background(), cfg, vector.MessageIndexSpec())
|
|
require.Error(t, err)
|
|
assert.ErrorIs(t, err, vector.ErrMirrorVersionMismatch,
|
|
"a stale-shape vectors.db must not be listed as if it were current")
|
|
assert.Contains(t, err.Error(), "embeddings build",
|
|
"the error must carry the rebuild remediation")
|
|
}
|