mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-08-30 16:53:21 +08:00
d3c03b2f34
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.
246 lines
9.2 KiB
Go
246 lines
9.2 KiB
Go
package chunkcmd
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
|
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
|
sdk "github.com/Tencent/WeKnora/client"
|
|
)
|
|
|
|
type listCall struct {
|
|
docID string
|
|
page int
|
|
pageSize int
|
|
}
|
|
|
|
type fakeListSvc struct {
|
|
calls []listCall
|
|
pages [][]sdk.Chunk
|
|
totals []int64
|
|
errs []error
|
|
callIdx int
|
|
}
|
|
|
|
func (f *fakeListSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int, _ ...string) ([]sdk.Chunk, int64, error) {
|
|
f.calls = append(f.calls, listCall{docID, page, pageSize})
|
|
defer func() { f.callIdx++ }()
|
|
if f.callIdx >= len(f.pages) {
|
|
return nil, 0, nil
|
|
}
|
|
return f.pages[f.callIdx], f.totals[f.callIdx], f.errs[f.callIdx]
|
|
}
|
|
|
|
func TestList_Happy(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{{
|
|
{ID: "c1", ChunkIndex: 0, Content: "hello", ChunkType: "text", IsEnabled: true},
|
|
{ID: "c2", ChunkIndex: 1, Content: "world", ChunkType: "text", IsEnabled: true},
|
|
}},
|
|
totals: []int64{2}, errs: []error{nil},
|
|
}
|
|
opts := &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}
|
|
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
require.Len(t, svc.calls, 1)
|
|
assert.Equal(t, "doc_abc", svc.calls[0].docID)
|
|
assert.Equal(t, 1, svc.calls[0].page)
|
|
assert.Equal(t, 50, svc.calls[0].pageSize)
|
|
// JSON mode emits a bare array; both ids must be present.
|
|
body := out.String()
|
|
assert.Contains(t, body, `"c1"`)
|
|
assert.Contains(t, body, `"c2"`)
|
|
}
|
|
|
|
// TestList_AllPages_StopsOnEmptyPage exercises the empty-page stop branch:
|
|
// total is large enough that page*pageSize < total never trips, so the loop
|
|
// can only terminate when the server returns an empty page.
|
|
func TestList_AllPages_StopsOnEmptyPage(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{
|
|
{{ID: "c1"}, {ID: "c2"}},
|
|
{{ID: "c3"}},
|
|
{}, // empty page → done
|
|
},
|
|
// Inflated total isolates the empty-page branch from the
|
|
// page*pageSize >= total branch.
|
|
totals: []int64{100, 100, 100},
|
|
errs: []error{nil, nil, nil},
|
|
}
|
|
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
|
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
assert.Equal(t, 3, len(svc.calls), "must stop on empty page, not loop forever")
|
|
}
|
|
|
|
// TestList_AllPages_StopsOnTotalExhausted exercises the page*pageSize >= total
|
|
// stop branch: server never returns an empty page in the requested window, so
|
|
// the loop must exit when accumulated coverage reaches total.
|
|
func TestList_AllPages_StopsOnTotalExhausted(t *testing.T) {
|
|
_, _ = iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{
|
|
{{ID: "c1"}, {ID: "c2"}},
|
|
{{ID: "c3"}},
|
|
},
|
|
totals: []int64{3, 3},
|
|
errs: []error{nil, nil},
|
|
}
|
|
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 1000}
|
|
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
// After page 2: page*pageSize=4 >= total=3 → stop. No 3rd request.
|
|
assert.Equal(t, 2, len(svc.calls), "must stop when total exhausted, no extra empty probe")
|
|
}
|
|
|
|
// TestList_AllPages_LimitTruncatesAccumulated exercises the limit-cap stop
|
|
// branch: pagination halts as soon as accumulated >= limit, and the result
|
|
// is sliced to exactly --limit items regardless of how the last page over-ran.
|
|
func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{
|
|
{{ID: "c1"}, {ID: "c2"}},
|
|
{{ID: "c3"}, {ID: "c4"}},
|
|
{{ID: "c5"}}, // should not be requested — limit hits first
|
|
},
|
|
totals: []int64{5, 5, 5},
|
|
errs: []error{nil, nil, nil},
|
|
}
|
|
opts := &ListOptions{DocID: "doc_abc", AllPages: true, PageSize: 2, Limit: 3}
|
|
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
// After page 2: accum=4 >= limit=3 → stop. Third page never requested.
|
|
assert.LessOrEqual(t, len(svc.calls), 2, "must not walk past limit-hit point")
|
|
// Result must be exactly --limit items (server returned 4, sliced to 3).
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
Data []sdk.Chunk `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
|
got := env.Data
|
|
assert.Len(t, got, 3, "accumulated must be sliced to exactly --limit")
|
|
// IDs preserve order: first 3 from the first 2 pages.
|
|
assert.Equal(t, []string{"c1", "c2", "c3"}, []string{got[0].ID, got[1].ID, got[2].ID})
|
|
}
|
|
|
|
// TestList_JSON_EmitsTotalCount pins that chunk list surfaces the document's
|
|
// full chunk count as meta.total_count (like doc/session/kb/model list), not
|
|
// just the returned count — an agent must be able to tell truncation from
|
|
// completeness.
|
|
func TestList_JSON_EmitsTotalCount(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{{{ID: "c1"}, {ID: "c2"}}},
|
|
totals: []int64{7},
|
|
errs: []error{nil},
|
|
}
|
|
opts := &ListOptions{DocID: "d1", Limit: 50, PageSize: 50}
|
|
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
var env struct {
|
|
Meta struct {
|
|
Count *int `json:"count"`
|
|
TotalCount *int `json:"total_count"`
|
|
} `json:"meta"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
|
require.NotNil(t, env.Meta.TotalCount, "chunk list must emit meta.total_count")
|
|
assert.Equal(t, 7, *env.Meta.TotalCount)
|
|
require.NotNil(t, env.Meta.Count)
|
|
assert.Equal(t, 2, *env.Meta.Count)
|
|
}
|
|
|
|
func TestList_LimitInvalid(t *testing.T) {
|
|
svc := &fakeListSvc{}
|
|
for _, lim := range []int{0, -1, 10001} {
|
|
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
|
require.Error(t, err, "expect error for --limit %d", lim)
|
|
assert.Contains(t, err.Error(), "input.invalid_argument")
|
|
}
|
|
}
|
|
|
|
func TestList_PageSizeInvalid(t *testing.T) {
|
|
svc := &fakeListSvc{}
|
|
for _, ps := range []int{0, -1, 1001} {
|
|
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
|
require.Error(t, err)
|
|
assert.Contains(t, err.Error(), "input.invalid_argument")
|
|
}
|
|
}
|
|
|
|
func TestList_MissingDoc_FlagError(t *testing.T) {
|
|
cmd := NewCmdList(nil)
|
|
cmd.SetArgs([]string{})
|
|
cmd.SilenceUsage = true
|
|
cmd.SilenceErrors = true
|
|
require.Error(t, cmd.Execute(), "expect required-flag error for missing --doc")
|
|
}
|
|
|
|
func TestList_Text_TableHeader(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{{
|
|
{ID: "c1", ChunkIndex: 0, Content: "the quick brown fox", ChunkType: "text", IsEnabled: true, UpdatedAt: "2026-05-15T12:00:00Z"},
|
|
}},
|
|
totals: []int64{1}, errs: []error{nil},
|
|
}
|
|
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
|
body := out.String()
|
|
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
|
|
assert.Contains(t, body, want)
|
|
}
|
|
}
|
|
|
|
func TestList_Text_PreviewTruncatedTo80(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
long := strings.Repeat("a", 200)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{{{ID: "c1", Content: long, ChunkType: "text", IsEnabled: true}}},
|
|
totals: []int64{1}, errs: []error{nil},
|
|
}
|
|
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
|
body := out.String()
|
|
// 80-col preview means we never see the 100th `a` from the content (only column truncation kicks in).
|
|
assert.NotContains(t, body, strings.Repeat("a", 100), "preview must be truncated to ~80 chars")
|
|
}
|
|
|
|
func TestList_JSON_BareArray(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{
|
|
pages: [][]sdk.Chunk{{
|
|
{ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_x"},
|
|
}},
|
|
totals: []int64{1}, errs: []error{nil},
|
|
}
|
|
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
Data []sdk.Chunk `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
|
got := env.Data
|
|
require.Len(t, got, 1)
|
|
assert.Equal(t, "doc_abc", got[0].KnowledgeID)
|
|
assert.Equal(t, "kb_x", got[0].KnowledgeBaseID)
|
|
// SDK snake_case keys must be present.
|
|
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
|
|
assert.NotContains(t, out.String(), `"doc_id"`)
|
|
}
|
|
|
|
func TestList_EmptyResultRendersBareArray(t *testing.T) {
|
|
out, _ := iostreams.SetForTest(t)
|
|
svc := &fakeListSvc{pages: [][]sdk.Chunk{{}}, totals: []int64{0}, errs: []error{nil}}
|
|
require.NoError(t, runList(context.Background(), &ListOptions{DocID: "doc_abc", Limit: 50, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
|
var env struct {
|
|
OK bool `json:"ok"`
|
|
Data []sdk.Chunk `json:"data"`
|
|
}
|
|
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
|
assert.True(t, env.OK)
|
|
assert.Len(t, env.Data, 0, "expected empty data array")
|
|
}
|