Files
WeKnora/cli/internal/cmdutil/batch_test.go
T
nullkey d3c03b2f34 feat(cli)!: v0.10 reliability, agent-UX, and command-surface hardening
A hardening + finalization pass over the agent-first CLI: correctness fixes,
richer machine-readable signals, flag/naming consistency, and a symmetric
config surface. Pre-1.0, so it includes breaking renames.

Correctness:
- agent update: resolve/validate --model/--rerank-model (was storing a bogus
  name verbatim, corrupting config.model_id).
- doctor: honor WEKNORA_HOST / WEKNORA_API_KEY (headless path no longer reports
  "no host configured").
- session ask / MCP session_ask: text answer was empty on non-TTY — the agent
  stream sets Done=true on an intermediate agent_query frame before the answer,
  and AgentAccumulator treated the first Done as terminal. Terminate on the
  `complete` event (new sdk.AgentResponseTypeComplete), not a per-frame Done.
- batch exit codes: any per-item failure collapses to operation.failed (exit 1),
  including `doc upload --recursive` partial failures — a permanent per-file
  failure (e.g. a duplicate) no longer surfaces as a retryable exit 7 an agent
  would loop on; per-item typed errors stay in the envelope.

Agent-first signals & discovery:
- error.exit_code in the envelope (type + exit_code disambiguate the
  input.invalid_argument exit-2-vs-5 split in one JSON read).
- meta.hint on empty content search and on draft doc create; doc wait fails fast
  on a never-parsing draft instead of hanging to --timeout.
- retrieval-readiness is visible in the natural flow: kb status / kb check emit
  retrieval_ready, and kb create hints the fix when no embedding model is bound
  — an unconfigured KB no longer looks silently healthy.
- schema contract completeness: every leaf declares output + >=1 example
  (drift-guarded); output strings match the meta actually emitted; chunk list
  and search docs now emit meta.total_count (both previously dropped it).
- schema tolerates a quoted multi-word command label; zero-state auth — and
  `link` with no profile — point at profile setup / the headless WEKNORA_KB_ID
  path instead of looping on `auth login`.
- id-addressed reads tolerate a redundant --kb (doc view/wait, chunk list/view
  accept and ignore it, declared in schema) so a carried-over --kb doesn't
  exit 2; streaming commands warn that --jq does not apply to an NDJSON stream.
- keep JSON-always as the default; --jq hints spell out the .data path.

Consistency & gating:
- doc create: drop the deprecated --name alias (--title only; pre-1.0 break).
- chunk list --limit aligned to 1..10000; model list --limit/-L with
  has_more/total_count; api write-gates -X PUT/PATCH (exit 10); skills install
  expands a leading ~.
- docs corrected: search docs / doc list --keyword help is case-insensitive
  (server does LOWER LIKE); AGENTS.md risk-action list (no phantom kb.init; add
  model.update / kb.config.set) and batch example (failed item carries `error`);
  session resume --message id comes from `message list`, not the stream.
- auth/profile ergonomics: env credentials are now first-class — `auth token`
  prints the active WEKNORA_API_KEY / WEKNORA_TOKEN, and auth login/logout/refresh
  give an env-aware message instead of looping on "run auth login". `auth logout`
  clears credentials but keeps the profile registered (host preserved for
  re-login); deleting a profile is `profile remove`'s job (clean logout/remove
  separation, matching gh / lark).

Config surface (symmetric read/write, in-place model edits):
- kb config now returns a secret-free KBModelConfigView (was {}); `kb config`
  reads, new `kb config set` writes; `kb init` removed (misnomer).
- kb create --chat-model: retrieval-ready in one step.
- model update: edit a model in place (id preserved, references survive) —
  rotate --api-key-stdin, change base-url / display-name / etc.
- session continue-stream renamed to session resume.

Docs: AGENTS.md is the single wire-contract source; CHANGELOG slimmed; stale
kb-init / continue-stream references removed; skill wire-vocab guard extended.
AGENTS.md / weknora-shared SKILL document retrieval_ready (a KB needs an
embedding model to be searchable), that --jq does not apply to NDJSON
streams, and the env-credential-first auth path; the KB quickstart example
now creates a retrieval-ready KB.
2026-07-06 22:53:01 +08:00

254 lines
8.0 KiB
Go

package cmdutil
import (
"bytes"
"context"
"errors"
"fmt"
"strings"
"testing"
"time"
)
// TestRunBatch_AllSuccess verifies that 3 ids all succeed: outcomes are
// ordered and summaryErr is nil.
func TestRunBatch_AllSuccess(t *testing.T) {
ids := []string{"a", "b", "c"}
op := func(_ context.Context, id string) error { return nil }
outcomes, err := RunBatch(context.Background(), ids, op)
if err != nil {
t.Fatalf("expected nil summaryErr; got %v", err)
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
for i, o := range outcomes {
if o.ID != ids[i] {
t.Errorf("outcomes[%d].ID = %q, want %q", i, o.ID, ids[i])
}
if o.Err != nil {
t.Errorf("outcomes[%d].Err = %v, want nil", i, o.Err)
}
}
}
// TestRunBatch_PartialFailure verifies that one failing id yields summaryErr
// with CodeOperationFailed while successful outcomes are preserved.
func TestRunBatch_PartialFailure(t *testing.T) {
ids := []string{"ok1", "fail", "ok2"}
errFail := errors.New("something went wrong")
op := func(_ context.Context, id string) error {
if id == "fail" {
return errFail
}
return nil
}
outcomes, summaryErr := RunBatch(context.Background(), ids, op)
if summaryErr == nil {
t.Fatal("expected non-nil summaryErr")
}
typedErr := AsError(summaryErr)
if typedErr == nil {
t.Fatalf("summaryErr is not *Error; got %T %v", summaryErr, summaryErr)
}
if typedErr.Code != CodeOperationFailed {
t.Errorf("summaryErr.Code = %q, want %q", typedErr.Code, CodeOperationFailed)
}
if !strings.Contains(typedErr.Message, "1/3") {
t.Errorf("summaryErr.Message = %q, expected 1/3 ratio", typedErr.Message)
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
if outcomes[1].Err != errFail {
t.Errorf("outcomes[1].Err = %v, want %v", outcomes[1].Err, errFail)
}
if outcomes[0].Err != nil || outcomes[2].Err != nil {
t.Error("expected outcomes[0] and outcomes[2] to have nil Err")
}
}
// TestRunBatch_StatusExitTriState verifies the batch tri-state exit mapping:
// all-success → exit 0 (nil summaryErr), partial → exit 1, all-fail → exit 1
// (any failure collapses to operation.failed). Pairs with the envelope-status
// tri-state in output.TestWriteBatchEnvelope_StatusTriState.
func TestRunBatch_StatusExitTriState(t *testing.T) {
failIf := func(fails map[string]bool) func(context.Context, string) error {
return func(_ context.Context, id string) error {
if fails[id] {
return errors.New("boom")
}
return nil
}
}
cases := []struct {
name string
ids []string
fails map[string]bool
wantExit int
}{
{"all_success", []string{"a", "b"}, nil, 0},
{"partial", []string{"a", "b"}, map[string]bool{"b": true}, 1},
{"all_fail", []string{"a", "b"}, map[string]bool{"a": true, "b": true}, 1},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
_, summaryErr := RunBatch(context.Background(), tc.ids, failIf(tc.fails))
if got := ExitCode(summaryErr); got != tc.wantExit {
t.Errorf("ExitCode = %d, want %d (summaryErr=%v)", got, tc.wantExit, summaryErr)
}
})
}
}
// TestRunBatch_ContextCancellation verifies that once the context is cancelled,
// remaining ids are marked with the context error without calling op.
func TestRunBatch_ContextCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
ids := []string{"first", "second", "third"}
opCalled := 0
op := func(ctx context.Context, id string) error {
opCalled++
if id == "first" {
cancel() // cancel after the first item
}
return nil
}
outcomes, summaryErr := RunBatch(ctx, ids, op)
// summaryErr must be non-nil (cancelled items counted as failed)
if summaryErr == nil {
t.Fatal("expected non-nil summaryErr due to cancellation")
}
if len(outcomes) != 3 {
t.Fatalf("expected 3 outcomes; got %d", len(outcomes))
}
// "second" and "third" should have ctx.Err() as their error
for _, id := range []string{"second", "third"} {
var found *BatchOutcome
for i := range outcomes {
if outcomes[i].ID == id {
found = &outcomes[i]
break
}
}
if found == nil {
t.Fatalf("missing outcome for id %q", id)
}
if !errors.Is(found.Err, context.Canceled) {
t.Errorf("outcome[%s].Err = %v, want context.Canceled", id, found.Err)
}
}
}
// TestEmitBatch_JSON_Envelope verifies that the JSON path emits a valid
// batch envelope with correct ok/error/result fields.
func TestEmitBatch_JSON_Envelope(t *testing.T) {
outcomes := []BatchOutcome{
{ID: "x", Err: nil},
{ID: "y", Err: NewError(CodeResourceNotFound, "not found")},
}
fopts := &FormatOptions{Mode: FormatJSON, TTY: false}
var buf bytes.Buffer
err := EmitBatch(outcomes, fopts, &buf, func(id string) any {
return map[string]any{"deleted_at": "2026-01-01T00:00:00Z"}
})
if err != nil {
t.Fatalf("EmitBatch error: %v", err)
}
got := buf.String()
if !strings.Contains(got, `"ok":true`) {
t.Errorf("expected ok:true in envelope; got %q", got)
}
if !strings.Contains(got, `"id":"x"`) {
t.Errorf("expected id:x; got %q", got)
}
if !strings.Contains(got, `"id":"y"`) {
t.Errorf("expected id:y; got %q", got)
}
if !strings.Contains(got, `"deleted_at":"2026-01-01T00:00:00Z"`) {
t.Errorf("expected result.deleted_at for x; got %q", got)
}
if !strings.Contains(got, `"type":"resource.not_found"`) {
t.Errorf("expected error.type for y; got %q", got)
}
// meta.failures should be 1
if !strings.Contains(got, `"failures":1`) {
t.Errorf("expected meta.failures:1; got %q", got)
}
}
// TestDeletedAtNow_FixedClock verifies that SetDeletedAtClock overrides the
// timestamp used by DeletedAtNow, making per-item values deterministic in tests.
func TestDeletedAtNow_FixedClock(t *testing.T) {
fixed := time.Date(2026, 5, 20, 0, 0, 0, 0, time.UTC)
defer SetDeletedAtClock(func() time.Time { return fixed })()
got := DeletedAtNow("irrelevant")
m, ok := got.(map[string]any)
if !ok {
t.Fatalf("DeletedAtNow returned %T, want map[string]any", got)
}
want := fixed.Format(time.RFC3339)
if m["deleted_at"] != want {
t.Errorf("deleted_at = %q, want %q", m["deleted_at"], want)
}
}
// TestEmitBatch_Text_PerLine verifies that the human/text path emits
// "OK <id>" / "FAIL <id>: <msg>" per line.
func TestEmitBatch_Text_PerLine(t *testing.T) {
outcomes := []BatchOutcome{
{ID: "x", Err: nil},
{ID: "y", Err: fmt.Errorf("boom")},
}
fopts := &FormatOptions{Mode: FormatText}
var buf bytes.Buffer
err := EmitBatch(outcomes, fopts, &buf, nil)
if err != nil {
t.Fatalf("EmitBatch error: %v", err)
}
got := buf.String()
if !strings.Contains(got, "OK x\n") {
t.Errorf("expected 'OK x' line; got %q", got)
}
if !strings.Contains(got, "FAIL y: boom\n") {
t.Errorf("expected 'FAIL y: boom' line; got %q", got)
}
}
// TestRunBatch_AllFailExit1 - any batch failure (partial OR all) collapses to
// operation.failed → exit 1. The authoritative per-item detail lives in the
// batch envelope (each item's typed error); the aggregate exit code is
// deliberately coarse.
func TestRunBatch_AllFailExit1(t *testing.T) {
notFound := func(_ context.Context, id string) error {
return NewError(CodeResourceNotFound, "no such thing "+id)
}
_, summaryErr := RunBatch(context.Background(), []string{"a", "b"}, notFound)
if got := ExitCode(summaryErr); got != 1 {
t.Errorf("all-fail batch ExitCode = %d, want 1; err=%v", got, summaryErr)
}
}
// TestRunBatch_ContextErrorsClassifiedPerItem verifies per-item context errors
// are classified as operation.cancelled / operation.timeout in the batch
// envelope (not the generic internal.error), so an agent inspecting the
// per-item results sees why each item aborted. (The aggregate exit stays 1.)
func TestRunBatch_ContextErrorsClassifiedPerItem(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
cancel() // pre-cancel so every item takes the ctx.Done branch
outcomes, _ := RunBatch(ctx, []string{"a"}, func(context.Context, string) error { return nil })
if got := ErrorToDetail(outcomes[0].Err).Type; got != string(CodeOperationCancelled) {
t.Errorf("per-item type = %q, want %q", got, CodeOperationCancelled)
}
}