feat(cli): chunk subtree + MCP chunk_list tool + curation rationale

New subtree (chunk list / view / delete) exposes RAG retrieval
debugging primitives with SDK-grounded field set (23 Chunk fields).
Pagination follows v0.4 canon: --limit / --page-size (1..1000) /
--all-pages.

- chunk list --doc <id>: enumerate by ChunkIndex (separate from
  search chunks which is hybrid retrieval; Long help documents the
  distinction)
- chunk view <id>: scope-less render via /chunks/by-id route; full
  content verbatim
- chunk delete <id> --doc <id>: scope-flag + scope-id; L-13
  destructive; 404 NOT idempotent; resource.not_found /
  auth.forbidden / input.confirmation_required typed exit codes
  documented in Long help

MCP server gains chunk_list as 10th curated tool. Schema deliberately
exposes only doc_id + limit (no pagination workflow on MCP); response
includes truncated_at_limit flag when total > limit.

cli/AGENTS.md MCP curation rationale rewritten: curated read-only is
a deliberate product call because the server side does not yet
enforce per-token scope. When server scope ships, mutation tools can
land in the MCP surface.

Shared helper cli/internal/text/timeago_string.go (FuzzyAgoStr)
extracted from session list during the C2 quality-review pass.
This commit is contained in:
nullkey
2026-05-16 01:22:14 +08:00
committed by lyingbug
parent 59132a56f6
commit 5b07c9ab87
15 changed files with 1111 additions and 19 deletions
+22 -1
View File
@@ -28,7 +28,7 @@ Key packages:
- `internal/secrets/``Store` interface; `KeyringStore` primary, `FileStore` 0600 fallback, `MemStore` for tests
- `internal/prompt/``TTYPrompter` (huh-based, password no-echo) + `AgentPrompter` (non-TTY no-prompt sentinel)
- `internal/sse/``Accumulator` for chat / agent invoke SSE streams
- `internal/mcp/` — curated stdio MCP server (wired by `cmd/mcp/serve.go`)
- `internal/mcp/` — curated 10-tool stdio MCP server (wired by `cmd/mcp/serve.go`); see [MCP tool surface](#mcp-tool-surface) for the curation rationale and inventory
- `client/` (parent module) — generated SDK
## Command Structure
@@ -165,3 +165,24 @@ Errors print to STDERR via `cmdutil.PrintError(w, err)` as `code: msg\nhint: ...
User-facing exit-code mapping lives in [README.md "Exit codes"](README.md#exit-codes). When adding a new `ErrorCode` constant, also append to `AllCodes()` so the acceptance contract picks it up.
## MCP Tool Surface
WeKnora's MCP server exposes a curated read-only tool surface. Many MCP servers in the wild ship write / mutation operations on by default and rely on credential-scope or sandbox restrictions for safety. WeKnora opts for curation instead: the server side doesn't yet enforce per-token scope, so an agent holding a user's token has full write access. Until server-side scope ships, the CLI keeps mutation tools out of the MCP surface as a belt-and-braces second line of defense. When server scope arrives this stance can loosen.
The curated 10 tools (`cli/internal/mcp/tools.go`):
| Tool | Purpose |
| --- | --- |
| `kb_list` | list knowledge bases |
| `kb_view` | fetch a knowledge base by id |
| `doc_list` | list documents in a kb (paginated, status-filterable) |
| `doc_view` | fetch a document by id |
| `doc_download` | download raw bytes (1 MiB cap, base64 for binary) |
| `chunk_list` | list chunks of a document for RAG retrieval debug |
| `search_chunks` | hybrid (vector + keyword) retrieval |
| `chat` | stream a RAG answer; auto-creates a session if absent |
| `agent_list` | list custom agents |
| `agent_invoke` | run a query through a custom agent |
Adding a tool is a deliberate API expansion — the agent-callable surface is the reason this CLI ships an MCP server, not its CLI command list, so the registration list in `registerTools` is maintained by hand.
+37
View File
@@ -0,0 +1,37 @@
// Package chunkcmd implements the `chunk` verb subtree for managing
// document chunks in a knowledge base. The directory is named `chunk/`
// (cobra noun-verb convention) but the Go package is `chunkcmd` to
// avoid colliding with cobra's *cobra.Command identifier.
//
// "chunk" in this subtree refers to indexed pieces of a knowledge
// document (server resource: GET/DELETE /chunks/...). Each document
// has many chunks; the chunking pipeline produces them at ingest time.
package chunkcmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
const chunkLong = `Manage and inspect document chunks.
Chunks are the indexed pieces of a knowledge document (1 doc → many chunks).
Use 'chunk list' to enumerate chunks in stored order (RAG admin / debug).
For relevance-ranked retrieval, use 'search chunks "<query>" --kb <id>'
instead — that runs hybrid vector + keyword scoring across all chunks.`
// NewCmdChunk builds the parent `chunk` command. Called from cli/cmd/root.go.
func NewCmdChunk(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "chunk <subcommand>",
Short: "Manage document chunks (RAG retrieval debug)",
Long: chunkLong,
Args: cobra.NoArgs,
Run: func(c *cobra.Command, _ []string) { _ = c.Help() },
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdView(f))
cmd.AddCommand(NewCmdDelete(f))
return cmd
}
+108
View File
@@ -0,0 +1,108 @@
package chunkcmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
// chunkDeleteFields enumerates the JSON discovery fields for `chunk delete`.
// Result payload is a tiny {id, deleted} object — mirrors `kb delete` /
// `agent delete`.
var chunkDeleteFields = []string{"id", "deleted"}
// DeleteOptions captures `chunk delete` flag state.
type DeleteOptions struct {
ChunkID string
DocID string // required: SDK DeleteChunk takes both ids in the route.
Yes bool // sourced from the global -y/--yes persistent flag
}
// DeleteService is the narrow SDK surface this command depends on.
type DeleteService interface {
DeleteChunk(ctx context.Context, docID, chunkID string) error
}
// deleteResult is the typed payload emitted on success in JSON mode.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
// Delete is NOT idempotent on a missing id — it surfaces resource.not_found
// (exit 4). Idempotent-already-true semantics are reserved for `unlink`-style
// local cleanups, not server-side resource removal. Mirrors `agent delete`
// and `kb delete`.
const chunkDeleteLong = `Permanently delete a chunk from a document.
Requires both the chunk id (positional) and the parent document id
(--doc) because the server route encodes both: DELETE /chunks/{doc}/{id}.
The CLI does not auto-resolve doc id from the chunk id because doing so
would add a round-trip and open a race with the ingest pipeline (a chunk
could move between documents between resolve and delete).
Prompts for confirmation by default when stdout is a TTY and --json is
not set. Pass -y/--yes (the global flag) to skip the prompt (required in
agent / CI / piped contexts).
Typed exit codes:
resource.not_found no chunk with the given id under that doc (exit 4)
auth.forbidden caller lacks delete permission on the chunk (exit 3)
input.confirmation_required destructive op without -y on a TTY (exit 10)
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`
const chunkDeleteExample = ` weknora chunk delete chunk_abc --doc doc_xyz # interactive confirm
weknora chunk delete chunk_abc --doc doc_xyz -y # no prompt
weknora chunk delete chunk_abc --doc doc_xyz -y --json # bare {id, deleted:true} JSON`
// NewCmdDelete builds `weknora chunk delete <chunk-id> --doc <doc-id>`.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <chunk-id> --doc <doc-id>",
Short: "Delete a chunk from a document (scoped)",
Long: chunkDeleteLong,
Example: chunkDeleteExample,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
return err
}
opts.ChunkID = args[0]
opts.Yes, _ = c.Flags().GetBool("yes")
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, jopts, cli, f.Prompter())
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunk lives under")
_ = cmd.MarkFlagRequired("doc")
cmdutil.AddJSONFlags(cmd, chunkDeleteFields)
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, jopts *cmdutil.JSONOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, jopts.Enabled(), "chunk", opts.ChunkID); err != nil {
return err
}
if err := svc.DeleteChunk(ctx, opts.DocID, opts.ChunkID); err != nil {
return cmdutil.WrapHTTP(err, "delete chunk %s", opts.ChunkID)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.ChunkID, Deleted: true})
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted chunk %s\n", opts.ChunkID)
return nil
}
+106
View File
@@ -0,0 +1,106 @@
package chunkcmd
import (
"context"
"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"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
type fakeChunkDeleteSvc struct {
gotDocID, gotChunkID string
err error
}
func (f *fakeChunkDeleteSvc) DeleteChunk(_ context.Context, docID, chunkID string) error {
f.gotDocID = docID
f.gotChunkID = chunkID
return f.err
}
func TestDelete_NonTTY_NoYes_ExitTen(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: false},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Empty(t, svc.gotChunkID, "must not call DeleteChunk without confirm")
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol")
}
func TestDelete_WithYes_PassesBothIDs(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}))
assert.Equal(t, "doc_abc", svc.gotDocID)
assert.Equal(t, "c1", svc.gotChunkID)
}
func TestDelete_MissingDoc_FlagError(t *testing.T) {
cmd := NewCmdDelete(nil)
cmd.SetArgs([]string{"c1"}) // no --doc
cmd.SilenceUsage = true
cmd.SilenceErrors = true
require.Error(t, cmd.Execute())
}
func TestDelete_404_PropagatesNotFound(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{err: errors.New("HTTP error 404: not found")}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "missing", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
func TestDelete_TTY_ConfirmYes_Calls(t *testing.T) {
_, _ = iostreams.SetForTestWithTTY(t)
svc := &fakeChunkDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
nil, svc, p))
assert.True(t, p.Asked)
assert.Equal(t, "c1", svc.gotChunkID)
}
func TestDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeChunkDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
err := runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc"},
nil, svc, p)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Empty(t, svc.gotChunkID, "answer=no must not call DeleteChunk")
assert.Contains(t, errBuf.String(), "Aborted")
}
func TestDelete_JSON_BareObject(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeChunkDeleteSvc{}
require.NoError(t, runDelete(context.Background(),
&DeleteOptions{ChunkID: "c1", DocID: "doc_abc", Yes: true},
&cmdutil.JSONOptions{}, svc, &testutil.ConfirmPrompter{}))
body := out.String()
assert.Contains(t, body, `"id":"c1"`)
assert.Contains(t, body, `"deleted":true`)
}
+189
View File
@@ -0,0 +1,189 @@
package chunkcmd
import (
"context"
"fmt"
"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/text"
sdk "github.com/Tencent/WeKnora/client"
)
const (
defaultPageSize = 50
maxPageSize = 1000
defaultLimit = 50
maxLimit = 1000
previewWidth = 80
)
// chunkListFields enumerates the fields surfaced for `--json` discovery on
// `chunk list`. Mirrors sdk.Chunk json tags — all 23 fields are projectable
// because chunk list returns bare SDK objects.
var chunkListFields = []string{
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
"tag_id", "content", "chunk_index", "is_enabled", "status",
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
"metadata", "content_hash", "image_info", "created_at", "updated_at",
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int) ([]sdk.Chunk, int64, error)
}
// ListOptions captures `chunk list` flag state.
type ListOptions struct {
// DocID scopes the listing to a single knowledge document (SDK
// `knowledge_id`). The chunks SDK does not expose a KB-wide route.
DocID string
// PageSize is the server batch size (1..1000, default 50).
PageSize int
// Limit caps the client-side accumulated slice (1..1000, default 50).
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
Limit int
// AllPages walks server pages internally until total exhausted or
// --limit hit. Mirrors session list / doc list canon.
AllPages bool
}
const chunkListLong = `List chunks under a document in stored order.
'chunk list --doc D' enumerates ALL chunks of document D in ChunkIndex
order (the per-doc ordinal assigned at ingest time). This is the
admin/debug surface for RAG retrieval — see what the chunking pipeline
produced, audit content, find a chunk id to view/delete.
For relevance-ranked retrieval (the RAG runtime surface), use
'search chunks "<query>" --kb K' instead. That command runs hybrid
vector + keyword scoring across all chunks of a knowledge base.
Typed exit codes:
input.invalid_argument --limit / --page-size out of 1..1000 range (exit 5)
resource.not_found no document with the given id (exit 4)
AI agents: prefer 'search chunks' for retrieval tasks. Use 'chunk list'
only when you need to enumerate / verify the chunking output of a
specific document.`
const chunkListExample = ` weknora chunk list --doc doc_abc
weknora chunk list --doc doc_abc --all-pages --page-size 100
weknora chunk list --doc doc_abc --json | jq '.[] | {id, chunk_index}'`
// NewCmdList builds `weknora chunk list --doc <doc-id>`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{PageSize: defaultPageSize, Limit: defaultLimit}
cmd := &cobra.Command{
Use: "list",
Short: "List chunks of a document (admin/debug, not retrieval)",
Long: chunkListLong,
Example: chunkListExample,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runList(c.Context(), opts, jopts, cli)
},
}
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Document id (SDK knowledge_id) to enumerate chunks for")
_ = cmd.MarkFlagRequired("doc")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return (1..1000)")
cmd.Flags().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
cmdutil.AddJSONFlags(cmd, chunkListFields)
return cmd
}
func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions, svc ListService) error {
if opts.Limit < 1 || opts.Limit > maxLimit {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..%d, got %d", maxLimit, opts.Limit),
}
}
if opts.PageSize < 1 || opts.PageSize > maxPageSize {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
}
}
var items []sdk.Chunk
if opts.AllPages {
accum := make([]sdk.Chunk, 0)
for page := 1; ; page++ {
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, page, opts.PageSize)
if err != nil {
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
}
accum = append(accum, chunks...)
if len(accum) >= opts.Limit {
accum = accum[:opts.Limit]
break
}
if len(chunks) == 0 || int64(page*opts.PageSize) >= total {
break
}
}
items = accum
} else {
chunks, _, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
if err != nil {
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
}
items = chunks
}
if items == nil {
items = []sdk.Chunk{} // JSON [] not null
}
if len(items) > opts.Limit {
items = items[:opts.Limit]
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, items)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no chunks)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "CHUNK_ID\tINDEX\tTYPE\tENABLED\tPREVIEW\tUPDATED")
now := time.Now()
for _, c := range items {
enabled := "-"
if c.IsEnabled {
enabled = "yes"
}
preview := text.Truncate(previewWidth, singleLine(c.Content))
if preview == "" {
preview = "-"
}
typ := c.ChunkType
if typ == "" {
typ = "-"
}
fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%s\t%s\n",
c.ID, c.ChunkIndex, typ, enabled, preview, text.FuzzyAgoStr(now, c.UpdatedAt))
}
return tw.Flush()
}
// singleLine collapses newlines/carriage-returns/tabs to spaces so the
// chunk preview fits on one row of the human table. Without this a
// multi-line chunk would smear across rows and break tabwriter alignment.
var singleLine = strings.NewReplacer("\n", " ", "\r", " ", "\t", " ").Replace
+205
View File
@@ -0,0 +1,205 @@
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) ([]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.JSONOptions{}, 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.JSONOptions{}, 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.JSONOptions{}, 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.JSONOptions{}, 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 got []sdk.Chunk
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
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})
}
func TestList_LimitInvalid(t *testing.T) {
svc := &fakeListSvc{}
for _, lim := range []int{0, -1, 1001} {
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.JSONOptions{}, 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.JSONOptions{}, 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_Human_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}, nil, svc))
body := out.String()
for _, want := range []string{"CHUNK_ID", "INDEX", "TYPE", "ENABLED", "PREVIEW", "UPDATED", "c1", "text"} {
assert.Contains(t, body, want)
}
}
func TestList_Human_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}, nil, 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.JSONOptions{}, svc))
var got []sdk.Chunk
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
require.Len(t, got, 1)
assert.Equal(t, "doc_abc", got[0].KnowledgeID)
assert.Equal(t, "kb_x", got[0].KnowledgeBaseID)
// Bare SDK keys, not custom CLI envelope.
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.JSONOptions{}, svc))
assert.Equal(t, "[]\n", out.String())
}
+152
View File
@@ -0,0 +1,152 @@
package chunkcmd
import (
"context"
"fmt"
"io"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// chunkViewFields enumerates the 23 SDK Chunk fields surfaced for `--json`
// discovery. JSON is bare SDK pass-through, so keys are snake_case
// (`knowledge_id`, `knowledge_base_id`) even though the human KV labels
// them as `doc_id` / `kb_id`.
var chunkViewFields = []string{
"id", "seq_id", "knowledge_id", "knowledge_base_id", "tenant_id",
"tag_id", "content", "chunk_index", "is_enabled", "status",
"start_at", "end_at", "pre_chunk_id", "next_chunk_id", "chunk_type",
"parent_chunk_id", "relation_chunks", "indirect_relation_chunks",
"metadata", "content_hash", "image_info", "created_at", "updated_at",
}
// ViewService is the narrow SDK surface this command depends on.
type ViewService interface {
GetChunkByIDOnly(ctx context.Context, chunkID string) (*sdk.Chunk, error)
}
// ViewOptions captures `chunk view` flag state. Chunk id is the sole input.
type ViewOptions struct {
ChunkID string
}
const chunkViewLong = `Show a single chunk with all SDK fields.
Human output is a key-value block; pass --json for the bare 23-field SDK
Chunk object. Content renders verbatim regardless of size — pipe to
less or use --json for large chunks. WeKnora chunks are typically bounded
by the ingest pipeline (~1000 tokens / a few KB), so unconditional full
rendering is reasonable.
Scope asymmetry with 'chunk delete':
view <id> scope-less (server: GET /chunks/by-id/{id})
delete <id> --doc D scoped (server: DELETE /chunks/{doc}/{id})
The asymmetry is deliberate. Auto-resolving doc id on delete would race
the ingest pipeline; forcing --doc on view would add friction with no
benefit. AI agents: a chunk id from any source (list, search, agent
invoke citation) is sufficient for view.
Typed exit codes:
resource.not_found no chunk with the given id (exit 4)`
const chunkViewExample = ` weknora chunk view chunk_abc
weknora chunk view chunk_abc --json | jq '.content'
weknora chunk view chunk_abc --json=id,chunk_index,is_enabled`
// NewCmdView builds `weknora chunk view <chunk-id>`.
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
opts := &ViewOptions{}
cmd := &cobra.Command{
Use: "view <chunk-id>",
Short: "Show a chunk's fields and content (scope-less)",
Long: chunkViewLong,
Example: chunkViewExample,
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
jopts, err := cmdutil.CheckJSONFlags(c)
if err != nil {
return err
}
opts.ChunkID = args[0]
cli, err := f.Client()
if err != nil {
return err
}
return runView(c.Context(), opts, jopts, cli)
},
}
cmdutil.AddJSONFlags(cmd, chunkViewFields)
return cmd
}
func runView(ctx context.Context, opts *ViewOptions, jopts *cmdutil.JSONOptions, svc ViewService) error {
ch, err := svc.GetChunkByIDOnly(ctx, opts.ChunkID)
if err != nil {
return cmdutil.WrapHTTP(err, "fetch chunk %s", opts.ChunkID)
}
if jopts.Enabled() {
return jopts.Emit(iostreams.IO.Out, ch)
}
renderChunk(iostreams.IO.Out, ch)
return nil
}
// renderChunk prints a single chunk in human-readable KV form per spec §1.5.2.
// Field order: id / seq_id / chunk_index / doc_id / kb_id / type / enabled /
// status (omit-zero) / start_at (omit-zero) / end_at (omit-zero) /
// tag_id (omit-empty) / image_info (omit-empty) / created_at / updated_at /
// content (full, no truncation, last entry).
//
// `doc_id` / `kb_id` are the human-friendly labels for the SDK fields
// `knowledge_id` / `knowledge_base_id`; JSON output keeps the SDK names.
func renderChunk(w io.Writer, c *sdk.Chunk) {
fmt.Fprintf(w, "id: %s\n", c.ID)
if c.SeqID != 0 {
fmt.Fprintf(w, "seq_id: %d\n", c.SeqID)
}
fmt.Fprintf(w, "chunk_index: %d\n", c.ChunkIndex)
if c.KnowledgeID != "" {
fmt.Fprintf(w, "doc_id: %s\n", c.KnowledgeID)
}
if c.KnowledgeBaseID != "" {
fmt.Fprintf(w, "kb_id: %s\n", c.KnowledgeBaseID)
}
if c.ChunkType != "" {
fmt.Fprintf(w, "type: %s\n", c.ChunkType)
}
enabled := "no"
if c.IsEnabled {
enabled = "yes"
}
fmt.Fprintf(w, "enabled: %s\n", enabled)
if c.Status != 0 {
fmt.Fprintf(w, "status: %d\n", c.Status)
}
if c.StartAt != 0 {
fmt.Fprintf(w, "start_at: %d\n", c.StartAt)
}
if c.EndAt != 0 {
fmt.Fprintf(w, "end_at: %d\n", c.EndAt)
}
if c.TagID != "" {
fmt.Fprintf(w, "tag_id: %s\n", c.TagID)
}
if c.ImageInfo != "" {
fmt.Fprintf(w, "image_info: %s\n", c.ImageInfo)
}
if c.CreatedAt != "" {
fmt.Fprintf(w, "created_at: %s\n", c.CreatedAt)
}
if c.UpdatedAt != "" {
fmt.Fprintf(w, "updated_at: %s\n", c.UpdatedAt)
}
// Content rendered verbatim, last entry, no truncation.
fmt.Fprintln(w)
fmt.Fprintln(w, "content:")
fmt.Fprintln(w, c.Content)
}
+100
View File
@@ -0,0 +1,100 @@
package chunkcmd
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 fakeViewSvc struct {
resp *sdk.Chunk
err error
}
func (f *fakeViewSvc) GetChunkByIDOnly(_ context.Context, _ string) (*sdk.Chunk, error) {
return f.resp, f.err
}
func TestView_Happy_RendersAllFields(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c1",
SeqID: 42,
ChunkIndex: 0,
KnowledgeID: "doc_abc",
KnowledgeBaseID: "kb_abc",
ChunkType: "text",
IsEnabled: true,
Content: "the quick brown fox",
CreatedAt: "2026-05-15T11:00:00Z",
UpdatedAt: "2026-05-15T12:00:00Z",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, nil, svc))
body := out.String()
assert.Contains(t, body, "c1")
assert.Contains(t, body, "doc_abc")
assert.Contains(t, body, "kb_abc")
assert.Contains(t, body, "text")
assert.Contains(t, body, "the quick brown fox", "content must render in full")
}
func TestView_HumanLabels_DocAndKB(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c1", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c1"}, nil, svc))
body := out.String()
// Human KV must use friendlier DOC_ID / KB_ID labels (spec §1.5.2), not raw SDK names.
assert.Contains(t, body, "doc_id")
assert.Contains(t, body, "kb_id")
assert.NotContains(t, body, "knowledge_id")
assert.NotContains(t, body, "knowledge_base_id")
}
func TestView_OmitsZeroOrEmpty(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{ID: "c_min", Content: "x"}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_min"}, nil, svc))
body := out.String()
// status / start_at / end_at all zero → must be omitted from the human KV.
assert.NotContains(t, body, "status:")
assert.NotContains(t, body, "start_at:")
assert.NotContains(t, body, "end_at:")
// tag_id / image_info empty → omitted.
assert.NotContains(t, body, "tag_id:")
assert.NotContains(t, body, "image_info:")
}
func TestView_JSON_BareSDKShape(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeViewSvc{resp: &sdk.Chunk{
ID: "c_json", KnowledgeID: "doc_abc", KnowledgeBaseID: "kb_abc",
}}
require.NoError(t, runView(context.Background(), &ViewOptions{ChunkID: "c_json"}, &cmdutil.JSONOptions{}, svc))
var got sdk.Chunk
require.NoError(t, json.Unmarshal(out.Bytes(), &got))
assert.Equal(t, "c_json", got.ID)
assert.Equal(t, "doc_abc", got.KnowledgeID)
// JSON uses SDK snake_case keys (knowledge_id), not human relabel doc_id.
assert.Contains(t, out.String(), `"knowledge_id":"doc_abc"`)
assert.Contains(t, out.String(), `"knowledge_base_id":"kb_abc"`)
assert.NotContains(t, out.String(), `"doc_id"`)
assert.NotContains(t, out.String(), `"kb_id"`)
}
func TestView_404(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeViewSvc{err: errors.New("HTTP error 404: not found")}
err := runView(context.Background(), &ViewOptions{ChunkID: "missing"}, nil, svc)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource.not_found")
}
+2
View File
@@ -11,6 +11,7 @@ import (
apicmd "github.com/Tencent/WeKnora/cli/cmd/api"
"github.com/Tencent/WeKnora/cli/cmd/auth"
chatcmd "github.com/Tencent/WeKnora/cli/cmd/chat"
chunkcmd "github.com/Tencent/WeKnora/cli/cmd/chunk"
contextcmd "github.com/Tencent/WeKnora/cli/cmd/context"
"github.com/Tencent/WeKnora/cli/cmd/doc"
"github.com/Tencent/WeKnora/cli/cmd/doctor"
@@ -130,6 +131,7 @@ hybrid searches against a WeKnora server from your shell or an AI agent.`,
cmd.AddCommand(chatcmd.NewCmd(f))
cmd.AddCommand(sessioncmd.NewCmd(f))
cmd.AddCommand(agentcmd.NewCmd(f))
cmd.AddCommand(chunkcmd.NewCmdChunk(f))
cmd.AddCommand(mcpcmd.NewCmd(f))
return cmd
}
+1 -15
View File
@@ -155,7 +155,7 @@ func runList(ctx context.Context, opts *ListOptions, jopts *cmdutil.JSONOptions,
if title == "" {
title = "-"
}
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, fuzzyTime(now, s.UpdatedAt))
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, text.FuzzyAgoStr(now, s.UpdatedAt))
}
return tw.Flush()
}
@@ -192,17 +192,3 @@ func parseSinceDuration(s string) (time.Duration, error) {
}
return d, nil
}
// fuzzyTime renders a server-provided timestamp string in "2d ago" form.
// Returns the raw input if parsing fails - better to surface the unknown
// format than to silently render "-".
func fuzzyTime(now time.Time, ts string) string {
if ts == "" {
return "-"
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return text.FuzzyAgo(now, t)
}
+2 -1
View File
@@ -35,9 +35,10 @@ type ServiceClient interface {
knowledgeService
chatService
agentService
chunkListService
}
// RunStdio constructs the MCP server, registers the curated 9 tools, and
// RunStdio constructs the MCP server, registers the curated 10 tools, and
// blocks reading JSON-RPC from stdin until the client disconnects or ctx
// is cancelled. Returns the underlying transport error (if any); the cobra
// RunE caller maps it through the usual cmdutil exit-code path.
+65 -1
View File
@@ -39,6 +39,13 @@ type agentService interface {
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback) error
}
// chunkListService is the narrow surface chunk_list depends on. Kept
// separate from knowledgeService because the chunk subtree is its own
// domain on the server side (/api/v1/chunks/...).
type chunkListService interface {
ListKnowledgeChunks(ctx context.Context, knowledgeID string, page, pageSize int) ([]sdk.Chunk, int64, error)
}
// agentInvokeService composes the two SDK methods agent_invoke needs
// (CreateSession for the auto-session path + AgentQAStreamWithRequest
// for the run itself). Declared here alongside the per-domain
@@ -49,7 +56,7 @@ type agentInvokeService interface {
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback) error
}
// registerTools wires the curated 9 tools onto server. Adding a tool here
// registerTools wires the curated 10 tools onto server. Adding a tool here
// is a deliberate API expansion - the agent-callable surface is the
// reason this CLI ships an MCP server, not its CLI command list, so this
// list must be maintained by hand.
@@ -63,6 +70,7 @@ func registerTools(server *mcpsdk.Server, svc ServiceClient) {
addChat(server, svc)
addAgentList(server, svc)
addAgentInvoke(server, svc)
addChunkList(server, svc)
}
// ---- kb_list -------------------------------------------------------------
@@ -445,6 +453,62 @@ func addAgentInvoke(server *mcpsdk.Server, svc agentInvokeService) {
})
}
// ---- chunk_list ----------------------------------------------------------
type chunkListInput struct {
DocID string `json:"doc_id" jsonschema:"document (knowledge entry) ID"`
Limit int `json:"limit,omitempty" jsonschema:"max chunks to return (1..1000); defaults to 50"`
}
type chunkListOutput struct {
Chunks []sdk.Chunk `json:"chunks"`
Total int64 `json:"total"`
TruncatedAtLimit bool `json:"truncated_at_limit"`
}
// chunkListDefaultLimit + chunkListMaxLimit mirror the schema's default+max.
// MCP schema deliberately exposes only `limit`, not the CLI's full
// --limit/--page/--page-size triple: LLM agents typically need a single
// bounded fetch, not pagination workflows. Above 1000, fall back to the CLI.
const (
chunkListDefaultLimit = 50
chunkListMaxLimit = 1000
)
func addChunkList(server *mcpsdk.Server, svc chunkListService) {
mcpsdk.AddTool(server, &mcpsdk.Tool{
Name: "chunk_list",
Description: "List chunks of a knowledge document for RAG retrieval debug. Returns at most `limit` chunks starting from ChunkIndex 0; if total chunks exceed limit, truncated_at_limit=true signals the agent to fall back to the CLI for paginated retrieval.",
}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in chunkListInput) (*mcpsdk.CallToolResult, chunkListOutput, error) {
if in.DocID == "" {
return nil, chunkListOutput{}, fmt.Errorf("doc_id is required")
}
// `limit` is typed as int by chunkListInput, so the SDK rejects
// non-numeric values at schema validation (e.g. "limit":"50")
// before this handler runs. Here we only default+clamp the
// already-decoded value.
limit := in.Limit
if limit < 1 {
limit = chunkListDefaultLimit
}
if limit > chunkListMaxLimit {
limit = chunkListMaxLimit
}
chunks, total, err := svc.ListKnowledgeChunks(ctx, in.DocID, 1, limit)
if err != nil {
return nil, chunkListOutput{}, fmt.Errorf("list knowledge chunks: %w", err)
}
if chunks == nil {
chunks = []sdk.Chunk{}
}
return nil, chunkListOutput{
Chunks: chunks,
Total: total,
TruncatedAtLimit: total > int64(limit),
}, nil
})
}
// encodeDownload returns (content, isBase64). Heuristic: if the first 512
// bytes contain a NUL, treat as binary. Otherwise it's UTF-8-ish text.
// Matches what /usr/bin/file's "binary" heuristic does at a coarse level -
+66 -1
View File
@@ -10,6 +10,8 @@ import (
"time"
mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
sdk "github.com/Tencent/WeKnora/client"
)
@@ -42,6 +44,9 @@ type fakeSvc struct {
agentErr error
agentEvents []*sdk.AgentStreamResponse
agentStreamErr error
chunks []sdk.Chunk
chunksTotal int64
chunksErr error
// Captured args:
calls struct {
listKBs int
@@ -59,6 +64,9 @@ type fakeSvc struct {
agentViewID string
agentReq *sdk.AgentQARequest
agentSess string
chunkDocID string
chunkPage int
chunkPageSize int
}
}
@@ -120,6 +128,12 @@ func (f *fakeSvc) AgentQAStreamWithRequest(_ context.Context, sess string, req *
}
return f.agentStreamErr
}
func (f *fakeSvc) ListKnowledgeChunks(_ context.Context, docID string, page, pageSize int) ([]sdk.Chunk, int64, error) {
f.calls.chunkDocID = docID
f.calls.chunkPage = page
f.calls.chunkPageSize = pageSize
return f.chunks, f.chunksTotal, f.chunksErr
}
// newTestServer wires svc to an in-process MCP server and returns a
// connected client session ready to CallTool against it.
@@ -182,7 +196,7 @@ func TestTool_ListsRegistered(t *testing.T) {
if err != nil {
t.Fatalf("ListTools: %v", err)
}
want := []string{"kb_list", "kb_view", "doc_list", "doc_view", "doc_download", "search_chunks", "chat", "agent_list", "agent_invoke"}
want := []string{"kb_list", "kb_view", "doc_list", "doc_view", "doc_download", "search_chunks", "chat", "agent_list", "agent_invoke", "chunk_list"}
got := map[string]bool{}
for _, tool := range res.Tools {
got[tool.Name] = true
@@ -405,3 +419,54 @@ func TestTool_AgentInvoke_StreamAbort(t *testing.T) {
t.Fatal("expected IsError=true on mid-stream abort")
}
}
func TestTool_ChunkList_Happy(t *testing.T) {
svc := &fakeSvc{
chunks: []sdk.Chunk{{ID: "c1", ChunkIndex: 0, Content: "hello"}},
chunksTotal: 1,
}
c, _ := newTestServer(t, svc)
var out chunkListOutput
callTool(t, c, "chunk_list", map[string]any{"doc_id": "doc_abc", "limit": 50}, &out)
require.Len(t, out.Chunks, 1)
assert.Equal(t, "c1", out.Chunks[0].ID)
assert.Equal(t, "doc_abc", svc.calls.chunkDocID)
assert.Equal(t, 1, svc.calls.chunkPage)
assert.Equal(t, 50, svc.calls.chunkPageSize) // SDK page=1, pageSize=limit
}
func TestTool_ChunkList_TruncatedAtLimit(t *testing.T) {
svc := &fakeSvc{
chunks: []sdk.Chunk{{ID: "c1"}},
chunksTotal: 100, // more than limit
}
c, _ := newTestServer(t, svc)
var out chunkListOutput
callTool(t, c, "chunk_list", map[string]any{"doc_id": "d", "limit": 1}, &out)
assert.True(t, out.TruncatedAtLimit)
}
func TestTool_ChunkList_MissingDocID(t *testing.T) {
c, _ := newTestServer(t, &fakeSvc{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{Name: "chunk_list", Arguments: map[string]any{"limit": 50}})
require.NoError(t, err)
require.True(t, res.IsError, "expected IsError=true on missing doc_id")
}
// TestTool_ChunkList_NonNumericLimit asserts the MCP framework rejects a
// string-valued `limit`. The schema declares limit as integer (via the
// chunkListInput struct tag), so non-numeric values fail validation
// before the handler runs.
func TestTool_ChunkList_NonNumericLimit(t *testing.T) {
c, _ := newTestServer(t, &fakeSvc{})
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
res, err := c.CallTool(ctx, &mcpsdk.CallToolParams{
Name: "chunk_list",
Arguments: map[string]any{"doc_id": "d", "limit": "50"},
})
require.NoError(t, err)
require.True(t, res.IsError, "expected IsError=true when limit is a string")
}
+25
View File
@@ -0,0 +1,25 @@
package text
import "time"
// FuzzyAgoStr is the string-input variant of FuzzyAgo for SDK types that
// carry timestamps as RFC3339 strings (sdk.Chunk.UpdatedAt, sdk.Session.
// UpdatedAt, ...) rather than time.Time.
//
// Behavior:
// - ts == "" → "-" (server has no timestamp; render placeholder)
// - parse error → ts (surface the raw value rather than hide it)
// - parse OK → FuzzyAgo(now, parsed)
//
// The parse-error fallback is deliberate: if the server starts emitting a
// new format we want it visible in the table, not silently replaced by "-".
func FuzzyAgoStr(now time.Time, ts string) string {
if ts == "" {
return "-"
}
t, err := time.Parse(time.RFC3339, ts)
if err != nil {
return ts
}
return FuzzyAgo(now, t)
}
+31
View File
@@ -0,0 +1,31 @@
package text_test
import (
"testing"
"time"
"github.com/Tencent/WeKnora/cli/internal/text"
)
func TestFuzzyAgoStr(t *testing.T) {
now := time.Date(2026, 5, 15, 12, 0, 0, 0, time.UTC)
tests := []struct {
name string
ts string
want string
}{
{"empty renders dash", "", "-"},
{"valid RFC3339 5m ago", now.Add(-5 * time.Minute).Format(time.RFC3339), "about 5 minutes ago"},
{"valid RFC3339 2d ago", now.Add(-2 * 24 * time.Hour).Format(time.RFC3339), "about 2 days ago"},
{"unparseable returned verbatim", "not-a-date", "not-a-date"},
{"wrong format returned verbatim", "2026-05-15 12:00:00", "2026-05-15 12:00:00"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := text.FuzzyAgoStr(now, tt.ts)
if got != tt.want {
t.Errorf("FuzzyAgoStr(%q) = %q, want %q", tt.ts, got, tt.want)
}
})
}
}