WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+204
View File
@@ -0,0 +1,204 @@
package search
import (
"context"
"fmt"
"strings"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
sdk "github.com/Tencent/WeKnora/client"
)
// chunksFields enumerates the fields surfaced for `--format json` discovery on
// `search chunks`. Filter applies to each SearchResult object in the bare
// array.
var chunksFields = []string{
"id", "content", "knowledge_id", "chunk_index", "knowledge_title",
"start_at", "end_at", "seq", "score", "match_type", "chunk_type",
"image_info", "metadata", "knowledge_filename", "knowledge_source",
"knowledge_channel", "matched_content",
}
type ChunksOptions struct {
Query string
KB string // raw --kb (UUID or name)
KBID string // resolved id; populated before HybridSearch
Limit int
VectorThreshold float64
KeywordThreshold float64
NoVector bool
NoKeyword bool
}
// ChunksService is the narrow SDK surface used by runChunks. *sdk.Client
// satisfies it; tests inject fakes via Factory.Client.
type ChunksService interface {
HybridSearch(
ctx context.Context,
kbID string,
params *sdk.SearchParams,
opts ...sdk.ResourceURLOptions,
) ([]*sdk.SearchResult, error)
}
var _ ChunksService = (*sdk.Client)(nil)
// NewCmdChunks builds `weknora search chunks "<query>" --kb <id-or-name>`.
// Uses a positional query argument with the KB selected via flag.
//
// The `--kb` flag accepts either a KB UUID (passed through unchanged) or a
// name (resolved via ListKnowledgeBases - see cmdutil.ResolveKBFlag).
func NewCmdChunks(f *cmdutil.Factory) *cobra.Command {
opts := &ChunksOptions{}
cmd := &cobra.Command{
Use: `chunks "<query>"`,
Short: "Hybrid (vector + keyword) chunk retrieval against a knowledge base",
Example: ` weknora search chunks "what is RAG?" --kb engineering
weknora search chunks "embedding model" --kb kb_abc --limit 20
weknora search chunks "retry policy" --kb engineering --no-keyword # vector-only`,
Long: `Hybrid (vector + keyword) retrieval against the knowledge base. Pass
--no-vector or --no-keyword to disable one channel; you cannot disable both.
--limit caps the returned slice client-side.`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if err := opts.validate(); err != nil {
return err
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Resolve KB via the shared flag→env→project-link chain (same as
// `doc list` / `chat`), so a linked directory or WEKNORA_KB_ID
// works without an explicit --kb. Resolve before building the
// client so an unresolved KB short-circuits to local.kb_id_required
// without a client round-trip.
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
opts.KBID = kbID
cli, err := f.Client()
if err != nil {
return err
}
return runChunks(c.Context(), opts, fopts, cli)
},
}
bindChunksFlags(cmd, opts)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Hybrid (vector + keyword) chunk retrieval against a knowledge base. The KB comes from --kb (id or name), else WEKNORA_KB_ID, else the linked directory. Results come with meta.count; use --limit to cap (default 8, tuned for RAG context). Pass --no-vector or --no-keyword to disable one channel.",
RequiredFlags: []string{"<query> (positional)", "--kb (or WEKNORA_KB_ID / linked directory)"},
Examples: []string{`weknora search chunks "what is RAG?" --kb engineering --format json`},
Output: "envelope.data is an array of SearchResult objects with id, content, score, knowledge_id; meta.count is the returned count; meta.has_more=true if more matched than --limit",
})
return cmd
}
// bindChunksFlags registers the chunks flag surface in one place to keep
// the constructor readable. --kb is optional: when omitted it falls back to
// WEKNORA_KB_ID / the linked directory via Factory.ResolveKB.
func bindChunksFlags(cmd *cobra.Command, opts *ChunksOptions) {
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 8, "Maximum results to return (default 8 - tuned for RAG context window; list commands default to 30)")
cmd.Flags().Float64Var(&opts.VectorThreshold, "vector-threshold", 0, "Vector retrieval similarity floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().Float64Var(&opts.KeywordThreshold, "keyword-threshold", 0, "Keyword retrieval score floor (per-channel, pre-fusion); 0 = no filter")
cmd.Flags().BoolVar(&opts.NoVector, "no-vector", false, "Disable the vector channel")
cmd.Flags().BoolVar(&opts.NoKeyword, "no-keyword", false, "Disable the keyword channel")
cmdutil.AddFormatFlag(cmd, chunksFields...)
}
// validate checks the option set before any SDK call. Limit bounds are
// enforced separately in RunE (user-input boundary) so internal callers
// can pass Limit==0 for the "no client-side cap" path.
func (o *ChunksOptions) validate() error {
if o.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if o.NoVector && o.NoKeyword {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--no-vector and --no-keyword cannot both be set")
}
return nil
}
func runChunks(ctx context.Context, opts *ChunksOptions, fopts *cmdutil.FormatOptions, svc ChunksService) error {
if err := opts.validate(); err != nil {
return err
}
if svc == nil {
return cmdutil.NewError(cmdutil.CodeServerError, "search chunks: no SDK client available")
}
params := &sdk.SearchParams{
QueryText: opts.Query,
MatchCount: opts.Limit,
VectorThreshold: opts.VectorThreshold,
KeywordThreshold: opts.KeywordThreshold,
DisableVectorMatch: opts.NoVector,
DisableKeywordsMatch: opts.NoKeyword,
}
results, err := svc.HybridSearch(ctx, opts.KBID, params)
if err != nil {
return cmdutil.WrapHTTP(err, "hybrid search")
}
// match_count is the server's *primary-match* cap - after that, the
// service appends parent / nearby / relation chunks as context
// enrichment, so the wire response can exceed Limit. Treat --limit as
// a hard return-count cap by trimming on the client. Recall isn't
// affected because the server's internal retrieval pool is already
// max(MatchCount*5, 50).
truncated := opts.Limit > 0 && len(results) > opts.Limit
if truncated {
results = results[:opts.Limit]
}
if fopts.WantsJSON() {
if results == nil {
results = []*sdk.SearchResult{}
}
meta := &output.Meta{Count: output.IntPtr(len(results)), HasMore: truncated, Hint: emptyContentSearchHint(len(results))}
return fopts.Emit(iostreams.IO.Out, results, meta)
}
return renderChunkResults(results, opts.KBID)
}
// renderChunkResults prints a compact pretty list. Minimal stopgap - a
// richer tabular renderer can replace this later without breaking the
// JSON contract.
func renderChunkResults(results []*sdk.SearchResult, kbID string) error {
if len(results) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no results)")
return nil
}
fmt.Fprintf(iostreams.IO.Out, "%d result(s) from kb=%s:\n\n", len(results), kbID)
for i, r := range results {
fmt.Fprintf(iostreams.IO.Out, "[%d] score=%.3f", i+1, r.Score)
if r.KnowledgeID != "" {
fmt.Fprintf(iostreams.IO.Out, " doc=%s", r.KnowledgeID)
}
fmt.Fprintln(iostreams.IO.Out)
fmt.Fprintln(iostreams.IO.Out, indent(strings.TrimSpace(r.Content), " "))
fmt.Fprintln(iostreams.IO.Out)
}
return nil
}
func indent(s, prefix string) string {
if s == "" {
return ""
}
lines := strings.Split(s, "\n")
for i, l := range lines {
lines[i] = prefix + l
}
return strings.Join(lines, "\n")
}
+249
View File
@@ -0,0 +1,249 @@
package search
import (
"context"
"encoding/json"
"errors"
"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 fakeChunksSvc struct {
results []*sdk.SearchResult
err error
gotKB string
gotQ string
}
func (f *fakeChunksSvc) HybridSearch(
_ context.Context, kbID string, p *sdk.SearchParams, _ ...sdk.ResourceURLOptions,
) ([]*sdk.SearchResult, error) {
f.gotKB = kbID
f.gotQ = p.QueryText
return f.results, f.err
}
func TestRunSearch_TextOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.92, Content: "first chunk", KnowledgeID: "doc-1", MatchType: sdk.MatchTypeVector},
{Score: 0.81, Content: "second chunk", KnowledgeID: "doc-2", MatchType: sdk.MatchTypeKeyword},
}}
opts := &ChunksOptions{Query: "hello", KBID: "kb_abc", Limit: 5}
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "kb_abc", svc.gotKB)
assert.Equal(t, "hello", svc.gotQ)
got := out.String()
assert.Contains(t, got, "2 result(s) from kb=kb_abc")
assert.Contains(t, got, "first chunk")
assert.Contains(t, got, "doc-1")
}
// JSON output must surface match_type so machine consumers / agents can
// reason about retrieval channels without re-implementing the wire format.
// (Text renderer keeps default minimal - diagnostic info opt-in via --format json.)
func TestRunSearch_JSONIncludesMatchType(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.9, Content: "x", MatchType: sdk.MatchTypeKeyword},
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Contains(t, out.String(), `"match_type":1`)
}
func TestRunSearch_JSONOutput(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{{Score: 0.9, Content: "x"}}}
opts := &ChunksOptions{Query: "q", KBID: "kb1", Limit: 1}
require.NoError(t, runChunks(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data []*sdk.SearchResult `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Contains(t, got, `"score":0.9`)
}
func TestRunSearch_EmptyResults(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: nil}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no results)")
}
// Server returns primary matches plus parent/related/nearby enrichment chunks,
// so the wire response can exceed Limit. CLI must trim to honor the user's
// hard-limit contract.
func TestRunSearch_LimitHardCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunksSvc{results: []*sdk.SearchResult{
{Score: 0.9, Content: "primary 1"},
{Score: 0.8, Content: "primary 2"},
{Score: 0.7, Content: "primary 3"},
{Score: 0, Content: "enrichment parent"}, // server-padded
{Score: 0, Content: "enrichment nearby"}, // server-padded
}}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", Limit: 3}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "3 result(s)")
assert.NotContains(t, got, "enrichment parent")
assert.NotContains(t, got, "enrichment nearby")
}
func TestRunSearch_BothChannelsDisabled(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1", NoVector: true, NoKeyword: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeChunksSvc{})
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_ServiceError_Transport(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: assert.AnError}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeNetworkError, typed.Code,
"non-HTTP-shaped errors classify as network.error so IsTransient picks them up")
}
func TestRunSearch_ServiceError_HTTPNotFound(t *testing.T) {
iostreams.SetForTest(t)
svc := &fakeChunksSvc{err: errors.New("HTTP error 404: knowledge base not found")}
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "missing"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestIndent(t *testing.T) {
assert.Equal(t, " foo\n bar", indent("foo\nbar", " "))
assert.Equal(t, "", indent("", " "))
}
func TestRunSearch_NilService(t *testing.T) {
iostreams.SetForTest(t)
err := runChunks(context.Background(), &ChunksOptions{Query: "q", KBID: "kb1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "server.error")
}
// TestNewCmdChunks_NoKBUsesResolver asserts that `search chunks "<query>"`
// without --kb no longer fails with cobra's `required flag(s) "kb"`. The
// command now resolves the KB through the shared flag→env→project-link chain
// (cmdutil.Factory.ResolveKB), matching `doc list` / `chat`; when nothing
// resolves it reports the typed local.kb_id_required (exit 1), not a cobra
// usage error. This is the inverse of the old lock test, which deliberately
// blocked the link fallback for this read path — the asymmetry with `doc
// list` was not worth keeping (a non-destructive search is the same risk
// profile as a list). The destructive `doc delete --all` keeps its explicit
// --kb rule; that safety guard is unrelated to this path.
func TestNewCmdChunks_NoKBUsesResolver(t *testing.T) {
iostreams.SetForTest(t)
t.Setenv("WEKNORA_KB_ID", "") // no ambient KB from env
t.Chdir(t.TempDir()) // no .weknora project link discoverable
cmd := NewCmdChunks(&cmdutil.Factory{
// Resolution reaches local.kb_id_required before any client is built,
// so this must never be invoked; make it loud if it is.
Client: func() (*sdk.Client, error) { return nil, errors.New("client should not be built") },
})
cmd.SetArgs([]string{"some query"}) // query but no --kb
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.NotContains(t, err.Error(), `required flag(s) "kb"`)
typed := cmdutil.AsError(err)
require.NotNil(t, typed)
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
}
// TestNewCmdChunks_HonorsKBEnv proves the env fallback is wired: with
// WEKNORA_KB_ID set and no --kb, KB resolution succeeds (no kb-required
// error) and the command proceeds to the client step — which here errors,
// confirming we got past resolution using the env value alone.
func TestNewCmdChunks_HonorsKBEnv(t *testing.T) {
iostreams.SetForTest(t)
t.Setenv("WEKNORA_KB_ID", "kb_from_env")
cmd := NewCmdChunks(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, errors.New("client boom") },
})
cmd.SetArgs([]string{"some query"}) // no --kb; env supplies it
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.NotContains(t, err.Error(), "kb is required")
assert.Contains(t, err.Error(), "client boom")
}
func TestNewCmdChunks_RequiresQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdChunks(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{}) // no query
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
}
func TestNewCmdChunks_RejectsEmptyQuery(t *testing.T) {
iostreams.SetForTest(t)
cmd := NewCmdChunks(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, nil },
})
cmd.SetArgs([]string{" ", "--kb", "kb1"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.Contains(t, err.Error(), "input.invalid_argument")
}
func TestRunSearch_NoVectorPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoVector: true,
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableVectorMatch)
assert.False(t, got.DisableKeywordsMatch)
}
func TestRunSearch_NoKeywordPassedThrough(t *testing.T) {
iostreams.SetForTest(t)
var got *sdk.SearchParams
svc := &capturingChunksSvc{capture: func(p *sdk.SearchParams) { got = p }}
require.NoError(t, runChunks(context.Background(), &ChunksOptions{
Query: "q", KBID: "kb1", NoKeyword: true,
}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
require.NotNil(t, got)
assert.True(t, got.DisableKeywordsMatch)
assert.False(t, got.DisableVectorMatch)
}
type capturingChunksSvc struct {
capture func(*sdk.SearchParams)
}
func (c *capturingChunksSvc) HybridSearch(
_ context.Context, _ string, p *sdk.SearchParams, _ ...sdk.ResourceURLOptions,
) ([]*sdk.SearchResult, error) {
c.capture(p)
return nil, nil
}
+196
View File
@@ -0,0 +1,196 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// docsPageSize is the default --page-size on `search docs`: how many
// entries to pull per ListKnowledgeWithFilter round-trip. The server
// applies the keyword filter pre-pagination, so most KBs return in a
// single page even at conservative sizes. Server caps page_size at 1000.
const docsPageSize = 200
// docsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
const docsMaxPageSize = 1000
// docsFields enumerates the fields surfaced for `--format json` discovery on
// `search docs`. Mirrors sdk.Knowledge json tags.
var docsFields = []string{
"id", "tenant_id", "knowledge_base_id", "tag_id", "type", "title",
"description", "source", "channel", "parse_status", "summary_status",
"enable_status", "embedding_model_id", "file_name", "file_type",
"file_size", "file_hash", "file_path", "storage_size", "metadata",
"created_at", "updated_at", "processed_at", "error_message",
}
type DocsSearchOptions struct {
Query string
KB string // raw --kb (UUID or name)
KBID string // resolved id; populated before listing
Limit int
// PageSize is the server batch size per ListKnowledgeWithFilter call
// (1..1000, default 200). Tunable so a caller searching a small KB
// can fetch everything in one round-trip, or a caller on flaky
// network can shorten the batch.
PageSize int
// AllPages walks server pages internally until total exhausted or
// --limit accumulated. Default true; setting false stops after the
// first page (useful for cheap previews).
AllPages bool
}
// DocsSearchService is the narrow SDK surface this command depends on.
// The server applies the keyword filter pre-pagination via the
// ?keyword= query param, so the CLI just forwards opts.Query as
// filter.Keyword and accumulates the (already-filtered) pages.
type DocsSearchService interface {
ListKnowledgeWithFilter(ctx context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error)
}
// NewCmdDocs builds `weknora search docs "<query>" --kb <id-or-name>`.
// Pages through the KB's documents and surfaces every entry whose title
// or file_name contains the query as a server-side case-insensitive LIKE
// match. Useful for finding a specific upload to download or delete.
func NewCmdDocs(f *cmdutil.Factory) *cobra.Command {
opts := &DocsSearchOptions{}
cmd := &cobra.Command{
Use: `docs "<query>"`,
Short: "Find documents in a knowledge base by keyword (server-side filter)",
Long: `Pages through the KB's documents, forwarding the query as the server-side
keyword filter (matched against title / file_name). Useful for finding a
specific upload to download or delete by id.
The query is a case-insensitive server-side LIKE filter (the server runs
` + "`LOWER(...) LIKE LOWER('%keyword%')`" + ` against title and file_name), so
` + "`FALCON`" + ` and ` + "`falcon`" + ` match the same documents.
By default, --all-pages=true walks every server page until --limit is
reached or the KB is exhausted. Pass --all-pages=false to stop after one page.`,
Example: ` weknora search docs "Q3 forecast" --kb finance
weknora search docs "spec" --kb engineering --limit 5
weknora search docs "spec" --kb engineering --all-pages=false`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
// Resolve KB via the shared flag→env→project-link chain (same as
// `doc list` / `chat`), so a linked directory or WEKNORA_KB_ID
// works without an explicit --kb. Resolve before building the
// client so an unresolved KB short-circuits to local.kb_id_required
// without a client round-trip.
kbID, err := f.ResolveKB(c)
if err != nil {
return err
}
opts.KBID = kbID
cli, err := f.Client()
if err != nil {
return err
}
return runDocsSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.KB, "kb", "", "Knowledge base UUID or name (overrides env / project link)")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmd.Flags().IntVar(&opts.PageSize, "page-size", docsPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
cmdutil.AddFormatFlag(cmd, docsFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Find documents in a knowledge base by keyword (server-side LIKE filter on title/file_name). The KB comes from --kb (id or name), else WEKNORA_KB_ID, else the linked directory. Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.",
RequiredFlags: []string{"<query> (positional)", "--kb (or WEKNORA_KB_ID / linked directory)"},
Examples: []string{`weknora search docs "spec" --kb engineering --format json`},
Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the returned count, meta.total_count the server's full match count, meta.has_more=true if more matched than --limit",
})
return cmd
}
func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, fopts *cmdutil.FormatOptions, svc DocsSearchService) error {
if opts.PageSize < 1 || opts.PageSize > docsMaxPageSize {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--page-size must be in 1..%d, got %d", docsMaxPageSize, opts.PageSize))
}
filter := sdk.KnowledgeListFilter{Keyword: opts.Query}
var matches []sdk.Knowledge
// Page through the KB until limit matches found or pagination exhausted.
// The server applies the keyword filter pre-pagination, so every item
// returned is already a match - no client-side filter needed.
// --all-pages=true (default) walks every server page; --all-pages=false
// stops after the first page. Termination counts records actually
// received so server-capped page_size doesn't truncate.
var serverTotal int64
for page := 1; ; page++ {
items, total, err := svc.ListKnowledgeWithFilter(ctx, opts.KBID, page, opts.PageSize, filter)
if err != nil {
return cmdutil.WrapHTTP(err, "list documents")
}
serverTotal = total
for _, k := range items {
matches = append(matches, k)
// Collect one past --limit so has_more is accurate; trimmed below.
if opts.Limit > 0 && len(matches) > opts.Limit {
goto done
}
}
if !opts.AllPages {
break
}
if int64(len(matches)) >= total || len(items) == 0 {
break
}
}
done:
sortKnowledgeByRecency(matches)
truncated := opts.Limit > 0 && len(matches) > opts.Limit
if truncated {
matches = matches[:opts.Limit]
}
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.Knowledge{}
}
meta := &output.Meta{Count: output.IntPtr(len(matches)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated, Hint: emptyContentSearchHint(len(matches))}
return fopts.Emit(iostreams.IO.Out, matches, meta)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tFILE\tTYPE\tUPDATED")
for _, k := range matches {
name := text.Truncate(50, text.KnowledgeDisplayName(k.FileName, k.Title, k.ID))
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", k.ID, name, k.FileType, k.UpdatedAt.Format("2006-01-02"))
}
return tw.Flush()
}
// sortKnowledgeByRecency sorts in place by UpdatedAt desc.
func sortKnowledgeByRecency(items []sdk.Knowledge) {
sort.Slice(items, func(i, j int) bool {
return items[i].UpdatedAt.After(items[j].UpdatedAt)
})
}
+300
View File
@@ -0,0 +1,300 @@
package search
import (
"context"
"encoding/json"
"errors"
"testing"
"time"
"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"
)
// fakeDocsSearchSvc scripts paginated ListKnowledgeWithFilter responses.
// Pages are indexed 1-based; items keyed by page. The last-received filter
// is captured so tests can assert opts.Query was threaded as filter.Keyword.
type fakeDocsSearchSvc struct {
pages map[int][]sdk.Knowledge
total int64
err error
calls []int // page numbers requested, for assertions
lastFilter sdk.KnowledgeListFilter
}
func (f *fakeDocsSearchSvc) ListKnowledgeWithFilter(_ context.Context, kbID string, page, pageSize int, filter sdk.KnowledgeListFilter) ([]sdk.Knowledge, int64, error) {
f.calls = append(f.calls, page)
f.lastFilter = filter
if f.err != nil {
return nil, 0, f.err
}
return f.pages[page], f.total, nil
}
func TestDocsSearch_Substring(t *testing.T) {
out, _ := iostreams.SetForTest(t)
// Server applies the keyword filter pre-pagination; the fake simulates
// that by only returning the matching items (d1/d3, not d2).
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{
1: {
{ID: "d1", Title: "Q3 Forecast", FileName: "q3.pdf", UpdatedAt: mustTime(t, "2026-05-10T00:00:00Z")},
{ID: "d3", Title: "Q3 retro", FileName: "retro.pdf", UpdatedAt: mustTime(t, "2026-05-11T00:00:00Z")},
},
},
total: 2,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "q3", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "q3", svc.lastFilter.Keyword, "query must be threaded as filter.Keyword")
got := out.String()
assert.Contains(t, got, "d1")
assert.Contains(t, got, "d3")
assert.NotContains(t, got, "d2")
}
func TestDocsSearch_MatchesFileName(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "Untitled", FileName: "report.pdf"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "report", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "d1")
}
// TestDocsSearch_PaginatesUntilTotal walks server-paginated results.
// Server-side filter has already been applied, so every returned item
// is in the result set; the runner just walks pages until total exhausted
// or --limit hit. With limit > total matches, we expect 2 pages.
func TestDocsSearch_PaginatesUntilTotal(t *testing.T) {
out, _ := iostreams.SetForTest(t)
page1 := make([]sdk.Knowledge, docsPageSize)
for i := range page1 {
page1[i] = sdk.Knowledge{ID: "p1", Title: "needle"}
}
page2 := []sdk.Knowledge{{ID: "found", Title: "needle here"}}
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: page1, 2: page2},
total: int64(docsPageSize) + 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: docsPageSize + 1, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "found")
assert.Equal(t, []int{1, 2}, svc.calls, "must page past the first batch when more items reported")
}
func TestDocsSearch_StopsAtLimit(t *testing.T) {
_, _ = iostreams.SetForTest(t)
page1 := make([]sdk.Knowledge, 50)
for i := range page1 {
page1[i] = sdk.Knowledge{ID: "match", Title: "needle"}
}
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page1}, total: 1000}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
// Must not request page 2 because limit was hit mid-page.
assert.Equal(t, []int{1}, svc.calls)
}
func TestDocsSearch_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "match"}}},
total: 1,
}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data []sdk.Knowledge `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Contains(t, got, `"id":"d1"`)
}
// TestDocsSearch_JSON_EmitsTotalCount pins that search docs surfaces the
// server's full match total as meta.total_count (server-side keyword filter, so
// total is the real match count) — parity with doc/session/chunk list.
func TestDocsSearch_JSON_EmitsTotalCount(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "match"}, {ID: "d2", Title: "match2"}}},
total: 9, // server reports 9 total matches; we display the first page
}
require.NoError(t, runDocsSearch(context.Background(),
&DocsSearchOptions{Query: "match", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: false},
&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, "search docs must emit meta.total_count")
assert.Equal(t, 9, *env.Meta.TotalCount)
}
func TestDocsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{err: errors.New("HTTP error 404: kb not found")}
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "x", KBID: "missing", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
// TestSearchDocs_AllPagesFlag_DefaultsTrue_WalksAllPages locks in that the
// historic walk-all-pages behavior is preserved when the new --all-pages flag
// is left at its default (true). Three pages of fake data, all match the
// substring; the run must request every page.
func TestSearchDocs_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{
1: {{ID: "d1", Title: "needle"}, {ID: "d2", Title: "needle"}},
2: {{ID: "d3", Title: "needle"}},
3: {},
},
total: 3,
}
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: true}
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
}
// TestSearchDocs_AllPagesFalse_StopsAtFirstPage asserts that --all-pages=false
// caps server round-trips at one, even when the server reports far more
// items available. New v0.5 opt-out for the walk-all default.
func TestSearchDocs_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{
pages: map[int][]sdk.Knowledge{1: {{ID: "d1", Title: "needle"}, {ID: "d2", Title: "needle"}}},
total: 100,
}
opts := &DocsSearchOptions{Query: "needle", KBID: "kb_abc", Limit: 100, PageSize: 2, AllPages: false}
require.NoError(t, runDocsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
}
// TestSearchDocs_KeywordPassedToFilter pins the v0.5 switch from client-side
// substring filtering to server-side ?keyword= via ListKnowledgeWithFilter.
// The query argument must arrive on the filter struct (not a discarded
// client-side variable).
func TestSearchDocs_KeywordPassedToFilter(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: {{ID: "d1"}}}, total: 1}
require.NoError(t, runDocsSearch(context.Background(), &DocsSearchOptions{Query: "my-query", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, "my-query", svc.lastFilter.Keyword, "Query must be threaded as filter.Keyword on ListKnowledgeWithFilter")
// Other filter fields must be empty - search docs only forwards the keyword.
assert.Empty(t, svc.lastFilter.ParseStatus)
assert.Empty(t, svc.lastFilter.FileType)
assert.Empty(t, svc.lastFilter.Source)
assert.Empty(t, svc.lastFilter.TagID)
}
// TestSearchDocs_PageSizeBound asserts the 1..1000 range guard mirrors the
// session/doc list cap. Out-of-range values must produce
// input.invalid_argument and never reach the SDK.
func TestSearchDocs_PageSizeBound(t *testing.T) {
for _, ps := range []int{0, -1, 1001} {
err := runDocsSearch(context.Background(), &DocsSearchOptions{Query: "t", KBID: "k", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeDocsSearchSvc{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code, "page_size=%d", ps)
}
}
func mustTime(t *testing.T, s string) time.Time {
t.Helper()
v, err := time.Parse(time.RFC3339, s)
require.NoError(t, err)
return v
}
// TestDocsSearch_HasMore asserts the meta.has_more truncation signal: true when
// more matches than --limit exist (over-fetch detects it, data trimmed to
// --limit), absent/false when the full result set fits. Mirrors the list
// commands' contract so an agent can tell its search was capped.
func TestDocsSearch_HasMore(t *testing.T) {
page := make([]sdk.Knowledge, 10)
for i := range page {
page[i] = sdk.Knowledge{ID: "match", Title: "needle"}
}
type meta struct {
Count int `json:"count"`
HasMore bool `json:"has_more"`
}
parse := func(t *testing.T, s string) meta {
var env struct {
Meta meta `json:"meta"`
}
require.NoError(t, json.Unmarshal([]byte(s), &env), "got %q", s)
return env.Meta
}
t.Run("truncated -> has_more true, data trimmed", func(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page}, total: 10}
require.NoError(t, runDocsSearch(context.Background(),
&DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 3, PageSize: docsPageSize, AllPages: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
m := parse(t, out.String())
assert.Equal(t, 3, m.Count, "data must be trimmed to --limit")
assert.True(t, m.HasMore, "has_more must be true when results exceed --limit")
})
t.Run("fits -> has_more false", func(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDocsSearchSvc{pages: map[int][]sdk.Knowledge{1: page[:2]}, total: 2}
require.NoError(t, runDocsSearch(context.Background(),
&DocsSearchOptions{Query: "needle", KBID: "kb1", Limit: 20, PageSize: docsPageSize, AllPages: true},
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
m := parse(t, out.String())
assert.Equal(t, 2, m.Count)
assert.False(t, m.HasMore, "has_more must be false/absent when results fit under --limit")
})
}
// TestNewCmdDocs_NoKBUsesResolver mirrors the chunks guard: `search docs`
// without --kb resolves the KB through the shared flag→env→project-link
// chain (Factory.ResolveKB), not cobra's required-flag check. With nothing
// to resolve it reports the typed local.kb_id_required, not a usage error.
func TestNewCmdDocs_NoKBUsesResolver(t *testing.T) {
iostreams.SetForTest(t)
t.Setenv("WEKNORA_KB_ID", "")
t.Chdir(t.TempDir())
cmd := NewCmdDocs(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, errors.New("client should not be built") },
})
cmd.SetArgs([]string{"some query"}) // query but no --kb
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.NotContains(t, err.Error(), `required flag(s) "kb"`)
typed := cmdutil.AsError(err)
require.NotNil(t, typed)
assert.Equal(t, cmdutil.CodeKBIDRequired, typed.Code)
}
// TestNewCmdDocs_HonorsKBEnv proves the env fallback is wired for search docs.
func TestNewCmdDocs_HonorsKBEnv(t *testing.T) {
iostreams.SetForTest(t)
t.Setenv("WEKNORA_KB_ID", "kb_from_env")
cmd := NewCmdDocs(&cmdutil.Factory{
Client: func() (*sdk.Client, error) { return nil, errors.New("client boom") },
})
cmd.SetArgs([]string{"some query"}) // no --kb; env supplies it
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
assert.NotContains(t, err.Error(), "kb is required")
assert.Contains(t, err.Error(), "client boom")
}
+140
View File
@@ -0,0 +1,140 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// kbSearchFields enumerates the fields surfaced for `--format json` discovery on
// `search kb`. Subset of KnowledgeBase suitable for list/filter results.
var kbSearchFields = []string{
"id", "name", "type", "description",
"is_temporary", "is_pinned",
"embedding_model_id", "summary_model_id",
"knowledge_count", "chunk_count",
"is_processing", "processing_count",
"created_at", "updated_at",
}
type KBSearchOptions struct {
Query string
Limit int
}
// KBSearchService is the narrow SDK surface this command depends on.
// Server has no fuzzy-KB-name endpoint; the CLI filters ListKnowledgeBases
// client-side. Acceptable because tenants typically have ≪ 1000 KBs.
type KBSearchService interface {
ListKnowledgeBases(ctx context.Context) ([]sdk.KnowledgeBase, error)
}
// NewCmdKB builds `weknora search kb "<query>"` - substring + case-insensitive
// match across KB names and descriptions visible to the active profile.
// Results are sorted by name length (shortest first; usually the closest
// hit) for deterministic output.
func NewCmdKB(f *cmdutil.Factory) *cobra.Command {
opts := &KBSearchOptions{}
cmd := &cobra.Command{
Use: `kb "<query>"`,
Short: "Find knowledge bases by name or description (client-side substring match)",
Long: `Substring + case-insensitive match across KB names and descriptions visible
to the active profile. Results are sorted by name length (shortest first;
usually the closest hit) for deterministic output.
This is name-discovery only - for searching *inside* a knowledge base's
content, use ` + "`weknora search chunks`" + `.`,
Example: ` weknora search kb "marketing"
weknora search kb "team" --limit 5 --format json`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runKBSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmdutil.AddFormatFlag(cmd, kbSearchFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Find knowledge bases by name or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap. For searching content inside a KB, use 'search chunks' instead.",
RequiredFlags: []string{"<query> (positional)"},
Examples: []string{`weknora search kb "engineering" --format json`},
Output: "envelope.data is an array of KnowledgeBase objects with id, name, knowledge_count; meta.count is the returned count; meta.has_more=true if more matched than --limit",
})
return cmd
}
func runKBSearch(ctx context.Context, opts *KBSearchOptions, fopts *cmdutil.FormatOptions, svc KBSearchService) error {
items, err := svc.ListKnowledgeBases(ctx)
if err != nil {
return cmdutil.WrapHTTP(err, "list knowledge bases")
}
matches := filterKBs(items, opts.Query)
truncated := opts.Limit > 0 && len(matches) > opts.Limit
if truncated {
matches = matches[:opts.Limit]
}
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.KnowledgeBase{}
}
meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated}
return fopts.Emit(iostreams.IO.Out, matches, meta)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tNAME\tDOCS")
for _, kb := range matches {
name := text.Truncate(50, kb.Name)
fmt.Fprintf(tw, "%s\t%s\t%s\n", kb.ID, name, text.Pluralize(int(kb.KnowledgeCount), "doc"))
}
return tw.Flush()
}
// filterKBs returns the KBs whose name or description contains q (case-
// insensitive), sorted by name length so the most-likely match shows
// first. Ties broken alphabetically for determinism.
func filterKBs(items []sdk.KnowledgeBase, q string) []sdk.KnowledgeBase {
needle := strings.ToLower(q)
out := make([]sdk.KnowledgeBase, 0, len(items))
for _, kb := range items {
if text.ContainsFold(needle, kb.Name, kb.Description) {
out = append(out, kb)
}
}
sort.Slice(out, func(i, j int) bool {
if len(out[i].Name) != len(out[j].Name) {
return len(out[i].Name) < len(out[j].Name)
}
return out[i].Name < out[j].Name
})
return out
}
+123
View File
@@ -0,0 +1,123 @@
package search
import (
"context"
"encoding/json"
"errors"
"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 fakeKBSearchSvc struct {
items []sdk.KnowledgeBase
err error
}
func (f *fakeKBSearchSvc) ListKnowledgeBases(_ context.Context) ([]sdk.KnowledgeBase, error) {
return f.items, f.err
}
func TestKBSearch_Substring(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "Marketing Q3", KnowledgeCount: 10},
{ID: "kb2", Name: "Engineering Docs", KnowledgeCount: 50},
{ID: "kb3", Name: "Marketing Q4 Plan", KnowledgeCount: 5},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "kb1")
assert.Contains(t, got, "kb3")
assert.NotContains(t, got, "Engineering")
}
func TestKBSearch_CaseInsensitive(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "ENGINEERING"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "engineering", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "kb1")
}
func TestKBSearch_MatchesDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb1", Name: "Engineering", Description: "all marketing docs are here"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "kb1")
}
func TestKBSearch_SortByNameLength(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "kb_long", Name: "very long name marketing"},
{ID: "kb_short", Name: "marketing"},
{ID: "kb_mid", Name: "marketing 2024"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
// Order: shortest name first.
iShort := strings.Index(got, "kb_short")
iMid := strings.Index(got, "kb_mid")
iLong := strings.Index(got, "kb_long")
assert.Less(t, iShort, iMid)
assert.Less(t, iMid, iLong)
}
func TestKBSearch_LimitHardCap(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{
{ID: "a", Name: "match-a"}, {ID: "b", Name: "match-b"},
{ID: "c", Name: "match-c"}, {ID: "d", Name: "match-d"},
}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "match", Limit: 2}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
count := 0
for _, id := range []string{"a", "b", "c", "d"} {
if strings.Contains(got, "match-"+id) {
count++
}
}
assert.Equal(t, 2, count)
}
func TestKBSearch_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{Name: "foo"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "bar", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
func TestKBSearch_JSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{items: []sdk.KnowledgeBase{{ID: "kb1", Name: "marketing"}}}
require.NoError(t, runKBSearch(context.Background(), &KBSearchOptions{Query: "marketing", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
got := out.String()
var env struct {
OK bool `json:"ok"`
Data []sdk.KnowledgeBase `json:"data"`
}
require.NoError(t, json.Unmarshal([]byte(got), &env), "expected valid JSON envelope, got: %q", got)
assert.True(t, env.OK, "envelope.ok must be true")
assert.Contains(t, got, `"id":"kb1"`)
}
func TestKBSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeKBSearchSvc{err: errors.New("HTTP error 401: unauthenticated")}
err := runKBSearch(context.Background(), &KBSearchOptions{Query: "x", Limit: 20}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
}
+47
View File
@@ -0,0 +1,47 @@
// Package search implements the `weknora search` command tree:
// chunks / kb / docs / sessions.
package search
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmdSearch builds the `weknora search` parent. Pure dispatcher to the
// four subcommands - users must pick a verb (chunks / kb / docs / sessions).
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "search",
Short: "Search across chunks, knowledge bases, documents, or sessions",
Long: `Verb-noun search tree:
search chunks "<q>" --kb X hybrid retrieval (RAG search)
search kb "<q>" find KBs by name / description
search docs "<q>" --kb X find documents inside a KB
search sessions "<q>" find chat sessions by title / description`,
Example: ` weknora search chunks "what is RAG?" --kb engineering
weknora search kb "marketing"
weknora search docs "Q3 forecast" --kb finance
weknora search sessions "onboarding"`,
}
cmd.AddCommand(NewCmdChunks(f))
cmd.AddCommand(NewCmdKB(f))
cmd.AddCommand(NewCmdDocs(f))
cmd.AddCommand(NewCmdSessions(f))
return cmd
}
// emptyContentSearchHint returns an actionable note when a KB-scoped content
// search (chunks / docs) yields zero results, so an agent can distinguish
// "no match" from "the KB has no indexed content". Empty when n > 0 so it
// never adds noise to real results.
func emptyContentSearchHint(n int) string {
if n > 0 {
return ""
}
return "0 results: this may be no match, OR the KB has no indexed chunks. " +
"Check `weknora kb status <kb>` (chunk_count) and `weknora doc list --kb <kb>` (parse_status); " +
"documents in parse_status=draft are not indexed — run `weknora doc reparse <doc-id>`."
}
+50
View File
@@ -0,0 +1,50 @@
package search
import (
"testing"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
)
// TestSearch_NoArgs_ShowsHelp: bare `weknora search` (no subcommand)
// must run cobra's Help() without erroring.
func TestSearch_NoArgs_ShowsHelp(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{})
cmd.SetArgs([]string{})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
require.NoError(t, cmd.Execute())
}
// TestSearch_RejectsPositional: bare positional `weknora search "<q>" --kb X`
// must error - search is a pure dispatcher with no shortcut form.
func TestSearch_RejectsPositional(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cmd := NewCmdSearch(&cmdutil.Factory{})
cmd.SetArgs([]string{"hello", "--kb", "kb_abc"})
cmd.SilenceErrors = true
cmd.SilenceUsage = true
err := cmd.Execute()
require.Error(t, err)
}
// TestSearch_SubcommandsRegistered: ensure chunks/kb/docs/sessions are
// reachable through the parent. Smoke-test only; the subcommands' own
// tests cover behavior.
func TestSearch_SubcommandsRegistered(t *testing.T) {
f := &cmdutil.Factory{}
cmd := NewCmdSearch(f)
names := map[string]bool{}
for _, c := range cmd.Commands() {
names[c.Name()] = true
}
for _, want := range []string{"chunks", "kb", "docs", "sessions"} {
if !names[want] {
t.Errorf("missing subcommand %q", want)
}
}
}
+184
View File
@@ -0,0 +1,184 @@
package search
import (
"context"
"fmt"
"sort"
"strings"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// sessionsPageSize is the default --page-size on `search sessions`: how many
// entries to pull per GetSessionsByTenant round-trip when paging through to
// filter client-side. Tunable via --page-size in 1..1000.
const sessionsPageSize = 200
// sessionsMaxPageSize bounds the --page-size flag, matching the session/doc list cap.
const sessionsMaxPageSize = 1000
// sessionsSearchFields enumerates the fields surfaced for `--format json` discovery
// on `search sessions`. Mirrors sdk.Session json tags.
var sessionsSearchFields = []string{
"id", "tenant_id", "title", "description", "created_at", "updated_at",
}
type SessionsSearchOptions struct {
Query string
Limit int
// PageSize is the server batch size per GetSessionsByTenant call
// (1..1000, default 200). Tunable so a caller searching a small set
// can fetch everything in one round-trip.
PageSize int
// AllPages walks server pages internally until total exhausted or
// --limit accumulated. Default true; setting false stops after the
// first page (useful for cheap previews).
AllPages bool
}
// SessionsSearchService is the narrow SDK surface this command depends on.
// Server has no session-search endpoint; CLI pages through and filters by
// Title / Description client-side.
type SessionsSearchService interface {
GetSessionsByTenant(ctx context.Context, page, pageSize int) ([]sdk.Session, int, error)
}
// NewCmdSessions builds `weknora search sessions "<query>"`. Finds chat
// sessions whose title or description contains the query.
func NewCmdSessions(f *cmdutil.Factory) *cobra.Command {
opts := &SessionsSearchOptions{}
cmd := &cobra.Command{
Use: `sessions "<query>"`,
Short: "Find chat sessions by title or description (client-side substring match)",
Long: `Pages through the tenant's chat sessions and surfaces every entry whose
title or description contains the query (case-insensitive).
By default, --all-pages=true walks every server page until --limit is
reached or the tenant's sessions are exhausted. Pass --all-pages=false
to stop after one page.`,
Example: ` weknora search sessions "onboarding"
weknora search sessions "Q3 review" --limit 3 --format json
weknora search sessions "Q3 review" --all-pages=false`,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Query = strings.TrimSpace(args[0])
if opts.Query == "" {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
}
if opts.Limit < 1 || opts.Limit > 1000 {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--limit must be between 1 and 1000")
}
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
return err
}
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
cli, err := f.Client()
if err != nil {
return err
}
return runSessionsSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return")
cmd.Flags().IntVar(&opts.PageSize, "page-size", sessionsPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", true, "Walk every server page until exhausted or --limit hit")
cmdutil.AddFormatFlag(cmd, sessionsSearchFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Find chat sessions by title or description (client-side case-insensitive substring match). Results come with meta.count; use --limit to cap and --all-pages=false to stop after one page.",
RequiredFlags: []string{"<query> (positional)"},
Examples: []string{`weknora search sessions "onboarding" --format json`},
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the returned count; meta.has_more=true if more matched than --limit",
})
return cmd
}
func runSessionsSearch(ctx context.Context, opts *SessionsSearchOptions, fopts *cmdutil.FormatOptions, svc SessionsSearchService) error {
if opts.PageSize < 1 || opts.PageSize > sessionsMaxPageSize {
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
fmt.Sprintf("--page-size must be in 1..%d, got %d", sessionsMaxPageSize, opts.PageSize))
}
needle := strings.ToLower(opts.Query)
var matches []sdk.Session
var received int // count of server-returned items so far (separate from
// matches, which is client-filtered). Used for termination so a
// server-capped page_size doesn't cause early break.
// --all-pages=true (default) walks every server page; --all-pages=false
// stops after the first page.
for page := 1; ; page++ {
items, total, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize)
if err != nil {
return cmdutil.WrapHTTP(err, "list sessions")
}
received += len(items)
for _, s := range items {
if matchSession(s, needle) {
matches = append(matches, s)
// Collect one past --limit so has_more is accurate; trimmed below.
if opts.Limit > 0 && len(matches) > opts.Limit {
goto done
}
}
}
if !opts.AllPages {
break
}
if received >= total || len(items) == 0 {
break
}
}
done:
sortSessionsByRecency(matches)
truncated := opts.Limit > 0 && len(matches) > opts.Limit
if truncated {
matches = matches[:opts.Limit]
}
if fopts.WantsJSON() {
if matches == nil {
matches = []sdk.Session{}
}
meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated}
return fopts.Emit(iostreams.IO.Out, matches, meta)
}
if len(matches) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tTITLE\tUPDATED")
now := time.Now()
for _, s := range matches {
title := text.Truncate(50, s.Title)
if title == "" {
title = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, text.FuzzyAgoStr(now, s.UpdatedAt))
}
return tw.Flush()
}
// matchSession reports whether title or description contains needle (already
// lowercased by caller).
func matchSession(s sdk.Session, needle string) bool {
return text.ContainsFold(needle, s.Title, s.Description)
}
// sortSessionsByRecency sorts in place by UpdatedAt desc. Server returns
// strings; we compare lexically - RFC3339 timestamps sort correctly that
// way, and a stable order is enough for output determinism even if a
// non-conforming string slips through.
func sortSessionsByRecency(items []sdk.Session) {
sort.SliceStable(items, func(i, j int) bool {
return items[i].UpdatedAt > items[j].UpdatedAt
})
}
+143
View File
@@ -0,0 +1,143 @@
package search
import (
"context"
"errors"
"testing"
"time"
"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 fakeSessionsSearchSvc struct {
pages map[int][]sdk.Session
total int
err error
calls []int
}
func (f *fakeSessionsSearchSvc) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) {
f.calls = append(f.calls, page)
if f.err != nil {
return nil, 0, f.err
}
return f.pages[page], f.total, nil
}
func TestSessionsSearch_TitleAndDescription(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {
{ID: "s1", Title: "Design review", UpdatedAt: "2026-05-12"},
{ID: "s2", Title: "Random", Description: "with design notes", UpdatedAt: "2026-05-11"},
{ID: "s3", Title: "Marketing", UpdatedAt: "2026-05-10"},
}},
total: 3,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "design", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
got := out.String()
assert.Contains(t, got, "s1")
assert.Contains(t, got, "s2")
assert.NotContains(t, got, "s3")
}
func TestSessionsSearch_NoMatches(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {{Title: "foo"}}},
total: 1,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "missing", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Contains(t, out.String(), "(no matches)")
}
func TestSessionsSearch_PaginatesAndStopsAtLimit(t *testing.T) {
_, _ = iostreams.SetForTest(t)
page1 := make([]sdk.Session, sessionsPageSize)
for i := range page1 {
page1[i] = sdk.Session{ID: "m", Title: "needle"}
}
svc := &fakeSessionsSearchSvc{pages: map[int][]sdk.Session{1: page1}, total: 1000}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 5, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
assert.Equal(t, []int{1}, svc.calls, "stops paging when limit reached")
}
func TestSessionsSearch_NetworkError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{err: errors.New("HTTP error 500: internal")}
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "x", Limit: 20, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
}
// TestSessionsSearch_RendersFuzzyTime is a regression guard for the v0.5
// audit bug: `search sessions` printed UpdatedAt as the raw RFC3339 string
// while `session list` ran it through text.FuzzyAgoStr — same SDK field,
// two human renderings. Asserts the human output now renders relative time
// (and does NOT contain the RFC3339 "T" date/time separator).
func TestSessionsSearch_RendersFuzzyTime(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {
{ID: "s1", Title: "needle", UpdatedAt: time.Now().Add(-2 * time.Hour).Format(time.RFC3339)},
}},
total: 1,
}
require.NoError(t, runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "needle", Limit: 10, PageSize: sessionsPageSize, AllPages: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
body := out.String()
assert.Contains(t, body, "hour", "must render relative time (e.g. 'about 2 hours ago'), not raw RFC3339")
assert.NotContains(t, body, "T0", "raw RFC3339 has 'T' between date and time; fuzzyTime output should not")
}
// TestSearchSessions_AllPagesFlag_DefaultsTrue_WalksAllPages locks in that
// the historic walk-all-pages behavior is preserved when --all-pages is left
// at its default (true). Three pages of fake data with matches on each;
// the run must request more than one page.
func TestSearchSessions_AllPagesFlag_DefaultsTrue_WalksAllPages(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{
1: {{ID: "s1", Title: "needle"}, {ID: "s2", Title: "needle"}},
2: {{ID: "s3", Title: "needle"}},
3: {},
},
total: 3,
}
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: true}
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.GreaterOrEqual(t, len(svc.calls), 2, "must walk multi pages by default")
}
// TestSearchSessions_AllPagesFalse_StopsAtFirstPage asserts that
// --all-pages=false caps server round-trips at one even when the server
// reports far more items available. New v0.5 opt-out for the walk-all default.
func TestSearchSessions_AllPagesFalse_StopsAtFirstPage(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSessionsSearchSvc{
pages: map[int][]sdk.Session{1: {{ID: "s1", Title: "needle"}, {ID: "s2", Title: "needle"}}},
total: 100,
}
opts := &SessionsSearchOptions{Query: "needle", Limit: 100, PageSize: 2, AllPages: false}
require.NoError(t, runSessionsSearch(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
assert.Len(t, svc.calls, 1, "must stop at first page when --all-pages=false")
}
// TestSearchSessions_PageSizeBound asserts the 1..1000 range guard mirrors
// the session/doc list cap. Out-of-range values must produce
// input.invalid_argument and never reach the SDK.
func TestSearchSessions_PageSizeBound(t *testing.T) {
for _, ps := range []int{0, -1, 1001} {
err := runSessionsSearch(context.Background(), &SessionsSearchOptions{Query: "t", Limit: 50, PageSize: ps}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeSessionsSearchSvc{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code, "page_size=%d", ps)
}
}