mirror of
https://github.com/Tencent/WeKnora.git
synced 2026-09-01 14:53:07 +08:00
feat(cli)!: v0.10 reliability, agent-UX, and command-surface hardening
A hardening + finalization pass over the agent-first CLI: correctness fixes,
richer machine-readable signals, flag/naming consistency, and a symmetric
config surface. Pre-1.0, so it includes breaking renames.
Correctness:
- agent update: resolve/validate --model/--rerank-model (was storing a bogus
name verbatim, corrupting config.model_id).
- doctor: honor WEKNORA_HOST / WEKNORA_API_KEY (headless path no longer reports
"no host configured").
- session ask / MCP session_ask: text answer was empty on non-TTY — the agent
stream sets Done=true on an intermediate agent_query frame before the answer,
and AgentAccumulator treated the first Done as terminal. Terminate on the
`complete` event (new sdk.AgentResponseTypeComplete), not a per-frame Done.
- batch exit codes: any per-item failure collapses to operation.failed (exit 1),
including `doc upload --recursive` partial failures — a permanent per-file
failure (e.g. a duplicate) no longer surfaces as a retryable exit 7 an agent
would loop on; per-item typed errors stay in the envelope.
Agent-first signals & discovery:
- error.exit_code in the envelope (type + exit_code disambiguate the
input.invalid_argument exit-2-vs-5 split in one JSON read).
- meta.hint on empty content search and on draft doc create; doc wait fails fast
on a never-parsing draft instead of hanging to --timeout.
- retrieval-readiness is visible in the natural flow: kb status / kb check emit
retrieval_ready, and kb create hints the fix when no embedding model is bound
— an unconfigured KB no longer looks silently healthy.
- schema contract completeness: every leaf declares output + >=1 example
(drift-guarded); output strings match the meta actually emitted; chunk list
and search docs now emit meta.total_count (both previously dropped it).
- schema tolerates a quoted multi-word command label; zero-state auth — and
`link` with no profile — point at profile setup / the headless WEKNORA_KB_ID
path instead of looping on `auth login`.
- id-addressed reads tolerate a redundant --kb (doc view/wait, chunk list/view
accept and ignore it, declared in schema) so a carried-over --kb doesn't
exit 2; streaming commands warn that --jq does not apply to an NDJSON stream.
- keep JSON-always as the default; --jq hints spell out the .data path.
Consistency & gating:
- doc create: drop the deprecated --name alias (--title only; pre-1.0 break).
- chunk list --limit aligned to 1..10000; model list --limit/-L with
has_more/total_count; api write-gates -X PUT/PATCH (exit 10); skills install
expands a leading ~.
- docs corrected: search docs / doc list --keyword help is case-insensitive
(server does LOWER LIKE); AGENTS.md risk-action list (no phantom kb.init; add
model.update / kb.config.set) and batch example (failed item carries `error`);
session resume --message id comes from `message list`, not the stream.
- auth/profile ergonomics: env credentials are now first-class — `auth token`
prints the active WEKNORA_API_KEY / WEKNORA_TOKEN, and auth login/logout/refresh
give an env-aware message instead of looping on "run auth login". `auth logout`
clears credentials but keeps the profile registered (host preserved for
re-login); deleting a profile is `profile remove`'s job (clean logout/remove
separation, matching gh / lark).
Config surface (symmetric read/write, in-place model edits):
- kb config now returns a secret-free KBModelConfigView (was {}); `kb config`
reads, new `kb config set` writes; `kb init` removed (misnomer).
- kb create --chat-model: retrieval-ready in one step.
- model update: edit a model in place (id preserved, references survive) —
rotate --api-key-stdin, change base-url / display-name / etc.
- session continue-stream renamed to session resume.
Docs: AGENTS.md is the single wire-contract source; CHANGELOG slimmed; stale
kb-init / continue-stream references removed; skill wire-vocab guard extended.
AGENTS.md / weknora-shared SKILL document retrieval_ready (a KB needs an
embedding model to be searchable), that --jq does not apply to NDJSON
streams, and the env-credential-first auth path; the KB quickstart example
now creates a retrieval-ready KB.
This commit is contained in:
@@ -167,7 +167,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
// Resolve --model / --rerank-model (id or name) and validate they
|
||||
// exist, matching the id-or-name policy of `kb init`. A bogus name
|
||||
// exist, matching the id-or-name policy of `kb config set`. A bogus name
|
||||
// fails fast here instead of creating an agent whose model never
|
||||
// resolves at run time.
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(cmd.Context(), cli, opts.Model, "KnowledgeQA"); err != nil {
|
||||
|
||||
@@ -93,6 +93,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete a custom agent",
|
||||
RequiredFlags: []string{"<agent-id> (positional)"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Examples: []string{
|
||||
"weknora agent delete ag_abc -y",
|
||||
"weknora agent delete ag_abc -y --format json",
|
||||
|
||||
@@ -21,6 +21,10 @@ import (
|
||||
type EditService interface {
|
||||
GetAgent(ctx context.Context, id string) (*sdk.Agent, error)
|
||||
UpdateAgent(ctx context.Context, id string, req *sdk.UpdateAgentRequest) (*sdk.Agent, error)
|
||||
// ListModels backs --model / --rerank-model id-or-name resolution so a
|
||||
// bogus name fails fast instead of clobbering config.model_id with an
|
||||
// unresolvable string (which never resolves at run time).
|
||||
ListModels(ctx context.Context) ([]sdk.Model, error)
|
||||
}
|
||||
|
||||
// EditOptions captures the surgical flag state. Both string fields and
|
||||
@@ -329,6 +333,20 @@ func runEdit(ctx context.Context, opts *EditOptions, fopts *cmdutil.FormatOption
|
||||
opts.SystemPrompt = strings.TrimSpace(string(body))
|
||||
}
|
||||
|
||||
// Resolve --model / --rerank-model (id or name) and validate they exist,
|
||||
// mirroring agent create. Without this a bogus name is stored verbatim as
|
||||
// config.model_id and the agent silently never resolves at run time.
|
||||
if opts.flags.modelSet {
|
||||
if opts.Model, err = cmdutil.ResolveModelRef(ctx, svc, opts.Model, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if opts.flags.rerankModelSet {
|
||||
if opts.RerankModel, err = cmdutil.ResolveModelRef(ctx, svc, opts.RerankModel, "Rerank"); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// Compute KB list from current + add/remove with a stderr warning when
|
||||
// the same id appears in both flags (net no-op, still idempotent).
|
||||
kbs := computeKBList(base.KnowledgeBases, opts.AddKBs, opts.RemoveKBs)
|
||||
|
||||
@@ -28,6 +28,8 @@ type fakeEditSvc struct {
|
||||
updateResp *sdk.Agent
|
||||
updateErr error
|
||||
updateCalls int
|
||||
models []sdk.Model
|
||||
modelsErr error
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) GetAgent(_ context.Context, _ string) (*sdk.Agent, error) {
|
||||
@@ -41,6 +43,40 @@ func (f *fakeEditSvc) UpdateAgent(_ context.Context, id string, req *sdk.UpdateA
|
||||
return f.updateResp, f.updateErr
|
||||
}
|
||||
|
||||
func (f *fakeEditSvc) ListModels(_ context.Context) ([]sdk.Model, error) {
|
||||
return f.models, f.modelsErr
|
||||
}
|
||||
|
||||
func TestEdit_ModelName_ResolvedToID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "good-llm", flags: editFlagSet{modelSet: true}}
|
||||
require.NoError(t, runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
require.NotNil(t, svc.updateReq)
|
||||
require.NotNil(t, svc.updateReq.Config)
|
||||
assert.Equal(t, "m-real", svc.updateReq.Config.ModelID, "--model name must resolve to the model id")
|
||||
}
|
||||
|
||||
func TestEdit_BogusModelName_RejectedNoWrite(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
getResp: &sdk.Agent{ID: "ag_abc", Name: "A", Config: &sdk.AgentConfig{ModelID: "old-id"}},
|
||||
updateResp: &sdk.Agent{ID: "ag_abc"},
|
||||
models: []sdk.Model{{ID: "m-real", Name: "good-llm", Type: "KnowledgeQA"}},
|
||||
}
|
||||
opts := &EditOptions{AgentID: "ag_abc", Model: "totally-bogus-model-xyz", flags: editFlagSet{modelSet: true}}
|
||||
err := runEdit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "a --model name matching no model must fail")
|
||||
var e *cmdutil.Error
|
||||
require.ErrorAs(t, err, &e)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, e.Code)
|
||||
assert.Equal(t, 0, svc.updateCalls, "must not write an agent with an unresolvable model")
|
||||
}
|
||||
|
||||
func TestEdit_FetchThenUpdate_PreservesUntouchedFields(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeEditSvc{
|
||||
|
||||
@@ -43,3 +43,67 @@ func TestEveryLeafCommandHasAgentHelp(t *testing.T) {
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
// renderAgentHelp runs a leaf's help under WEKNORA_AGENT_HELP=1 and decodes the
|
||||
// machine blob (used_for / output / examples) an agent would read.
|
||||
func renderAgentHelp(t *testing.T, c *cobra.Command) struct {
|
||||
UsedFor string `json:"used_for"`
|
||||
Output string `json:"output"`
|
||||
Examples []string `json:"examples"`
|
||||
} {
|
||||
t.Helper()
|
||||
var buf bytes.Buffer
|
||||
c.SetOut(&buf)
|
||||
c.Help()
|
||||
var ah struct {
|
||||
UsedFor string `json:"used_for"`
|
||||
Output string `json:"output"`
|
||||
Examples []string `json:"examples"`
|
||||
}
|
||||
if err := json.Unmarshal(buf.Bytes(), &ah); err != nil {
|
||||
t.Fatalf("%s: agent-help is not JSON: %v", c.CommandPath(), err)
|
||||
}
|
||||
return ah
|
||||
}
|
||||
|
||||
// TestEveryLeafCommandDeclaresOutput enforces that every leaf command tells an
|
||||
// agent what its stdout carries. Even side-effect commands describe their
|
||||
// envelope (e.g. deletes emit {id, deleted:true}); an empty Output is a contract
|
||||
// gap, not a valid state. Sibling drift guard to the agent-help test above.
|
||||
func TestEveryLeafCommandDeclaresOutput(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
var missing []string
|
||||
eachLeafCommand(root, func(c *cobra.Command) {
|
||||
if renderAgentHelp(t, c).Output == "" {
|
||||
missing = append(missing, c.CommandPath())
|
||||
}
|
||||
})
|
||||
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("leaf commands missing agent-help Output (declare AgentHelp.Output):\n %v\n(%d commands)",
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
// TestEveryLeafCommandHasExample enforces that every leaf ships at least one
|
||||
// runnable example — agents learn invocation shape from examples, not prose.
|
||||
func TestEveryLeafCommandHasExample(t *testing.T) {
|
||||
t.Setenv("WEKNORA_AGENT_HELP", "1")
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
var missing []string
|
||||
eachLeafCommand(root, func(c *cobra.Command) {
|
||||
if len(renderAgentHelp(t, c).Examples) == 0 {
|
||||
missing = append(missing, c.CommandPath())
|
||||
}
|
||||
})
|
||||
|
||||
sort.Strings(missing)
|
||||
if len(missing) > 0 {
|
||||
t.Errorf("leaf commands missing agent-help Examples (declare AgentHelp.Examples):\n %v\n(%d commands)",
|
||||
missing, len(missing))
|
||||
}
|
||||
}
|
||||
|
||||
+32
-3
@@ -121,11 +121,21 @@ Examples:
|
||||
}
|
||||
method := resolveMethod(opts)
|
||||
// Escape-hatch DELETE through `weknora api` is just as destructive
|
||||
// as `weknora kb delete` - exit-10 protocol must apply (cli/README.md).
|
||||
if method == http.MethodDelete {
|
||||
// as `weknora kb delete` - exit-10 destructive protocol must apply
|
||||
// (cli/README.md). PUT/PATCH mutate server state like a typed
|
||||
// `kb/agent/doc update`, so they get the same exit-10 WRITE gate;
|
||||
// without it the raw escape hatch bypassed the "an agent cannot
|
||||
// silently mutate" guarantee. POST stays ungated to match typed
|
||||
// `create` (also ungated). GET/HEAD are reads.
|
||||
switch method {
|
||||
case http.MethodDelete:
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "endpoint", args[0], "api.delete", []string{"weknora", "api", "-X", "DELETE", args[0], "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
case http.MethodPut, http.MethodPatch:
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), opts.Yes, fopts.WantsJSON(), "write", "endpoint", args[0], "api."+strings.ToLower(method), apiRetryArgv(opts, method, args[0])); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
@@ -154,7 +164,8 @@ Examples:
|
||||
},
|
||||
Output: "text mode (default): the raw server response body on stdout. json mode: the parsed server response is placed directly under envelope.data — project with --jq '.data...' at the server's own depth (e.g. '.data.data[]' for a list endpoint, '.data.data.id' for a created object). With --paginate, envelope.data is the merged {data, total}.",
|
||||
Warnings: []string{
|
||||
"Only -X DELETE is confirmation-gated (exit 10 / input.confirmation_required unless -y); -X GET/POST/PUT/PATCH and other methods are unguarded — you own the safety of writes made through this escape hatch.",
|
||||
"-X DELETE is destructive-gated and -X PUT/PATCH are write-gated (exit 10 / input.confirmation_required unless -y), matching typed delete/update. -X POST (create-shaped) and GET are unguarded — you own the safety of creates made through this escape hatch.",
|
||||
"Raw passthrough: the typed error envelope does NOT fully apply. The server's own response goes under envelope.data at its native depth; a non-2xx HTTP status surfaces via the exit code, not a typed error.type/retry_argv. Do not rely on error.type/retryable for `api` the way you do for typed subcommands.",
|
||||
"Raw HTTP passthrough; agents should prefer typed subcommands (kb/doc/session/...) when available.",
|
||||
},
|
||||
})
|
||||
@@ -293,6 +304,24 @@ func resolveMethod(opts *Options) string {
|
||||
return "GET"
|
||||
}
|
||||
|
||||
// apiRetryArgv reconstructs a directly-executable `weknora api` argv (with -y)
|
||||
// for the write-confirmation gate, preserving the method, path and body flags
|
||||
// the caller passed so an agent can re-run the exact mutation after approval.
|
||||
func apiRetryArgv(opts *Options, method, path string) []string {
|
||||
argv := []string{"weknora", "api", "-X", method, path}
|
||||
switch {
|
||||
case opts.Data != "":
|
||||
argv = append(argv, "-d", opts.Data)
|
||||
case opts.Input != "":
|
||||
argv = append(argv, "--input", opts.Input)
|
||||
default:
|
||||
for _, f := range opts.Fields {
|
||||
argv = append(argv, "-F", f)
|
||||
}
|
||||
}
|
||||
return append(argv, "-y")
|
||||
}
|
||||
|
||||
// runAPI is the testable core: validate inputs, dispatch via Service.Raw,
|
||||
// classify status, and emit either the raw body or a JSON object. The
|
||||
// caller is responsible for resolving the method (defaults / auto-POST)
|
||||
|
||||
@@ -551,3 +551,55 @@ func TestAPI_PaginateServerCapsPageSize(t *testing.T) {
|
||||
t.Errorf("got %d records, want 5 (server-capped page_size should not cause truncation)", len(got.Data))
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_PUT_RequiresConfirmation pins the exit-10 write gate on the
|
||||
// escape-hatch PUT path: `weknora api -X PUT /...` mutates server state the
|
||||
// same way a typed `kb update` does, so it must require -y (exit 10) rather
|
||||
// than silently writing. Regression: only DELETE was gated, letting an agent
|
||||
// bypass the write-confirmation protocol via raw PUT/PATCH.
|
||||
func TestAPI_PUT_RequiresConfirmation(t *testing.T) {
|
||||
for _, method := range []string{"PUT", "PATCH"} {
|
||||
t.Run(method, func(t *testing.T) {
|
||||
iostreams.SetForTest(t) // non-TTY
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases/kb_xxx", "-X", method, "-F", "name=x")
|
||||
err := root.Execute()
|
||||
if err == nil {
|
||||
t.Fatalf("expected confirmation_required for %s without -y", method)
|
||||
}
|
||||
var ce *cmdutil.Error
|
||||
if !asTypedError(err, &ce) || ce.Code != cmdutil.CodeInputConfirmationRequired {
|
||||
t.Errorf("want input.confirmation_required, got %v", err)
|
||||
}
|
||||
if got := cmdutil.ExitCode(err); got != 10 {
|
||||
t.Errorf("exit code = %d, want 10", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPI_POST_NotGated: POST is create-shaped and, like typed `kb create`,
|
||||
// intentionally ungated — it must reach the SDK without a confirmation gate.
|
||||
func TestAPI_POST_NotGated(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
called := false
|
||||
cli, stop := newTestClient(t, func(w http.ResponseWriter, r *http.Request) {
|
||||
called = true
|
||||
w.WriteHeader(http.StatusOK)
|
||||
})
|
||||
defer stop()
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return cli, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootHarness(NewCmd(f), "/api/v1/knowledge-bases", "-X", "POST", "-F", "name=x")
|
||||
if err := root.Execute(); err != nil {
|
||||
t.Fatalf("execute: %v", err)
|
||||
}
|
||||
if !called {
|
||||
t.Error("POST handler not called - POST must not be gated")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,8 +128,12 @@ func resolveActiveProfile(f *cmdutil.Factory) (name, host string, err error) {
|
||||
}
|
||||
active := cfg.CurrentProfile
|
||||
if active == "" {
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile; run `weknora profile add <name> --host <h> --use` first")
|
||||
msg := "no active profile; run `weknora profile add <name> --host <h> --use` first"
|
||||
if envActive, kind := cmdutil.EnvCredential(); envActive {
|
||||
msg = "no active profile to log in — you are already authenticated this session via " + kind +
|
||||
"; `auth login` only persists a named profile, so run `weknora profile add <name> --host <h> --use` first if you want one"
|
||||
}
|
||||
return "", "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, msg)
|
||||
}
|
||||
prof, ok := cfg.Profiles[active]
|
||||
if !ok {
|
||||
@@ -310,9 +314,9 @@ func applyUser(prof *config.Profile, user *sdk.AuthUser) {
|
||||
// loginResult is the typed payload emitted by `--format json`. mode is derived from
|
||||
// whether the server returned a user (password flow) vs API-key flow.
|
||||
type loginResult struct {
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
|
||||
Profile string `json:"profile"`
|
||||
Host string `json:"host"`
|
||||
Mode string `json:"mode"` // ModeBearer or ModeAPIKey
|
||||
// Email is the authenticated principal's email. Named "email" (not
|
||||
// "user") so the identity field matches `auth status`, which also
|
||||
// exposes it as `email` — one key for one concept across both commands.
|
||||
|
||||
+18
-9
@@ -61,6 +61,10 @@ accepted until it expires.`,
|
||||
return cfgErr
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
if _, err := pickLogoutTargets(opts, cfg); err != nil {
|
||||
@@ -86,7 +90,7 @@ accepted until it expires.`,
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "auth.logout")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "clear stored credentials for the active profile (or all) and remove the profile from config",
|
||||
UsedFor: "clear stored credentials for the active profile (or --all); the profile itself stays registered (host preserved) so `auth login` can re-auth it — use `profile remove` to delete the profile entirely",
|
||||
Examples: []string{
|
||||
"weknora auth logout",
|
||||
"weknora --profile staging auth logout",
|
||||
@@ -107,6 +111,10 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
return err
|
||||
}
|
||||
if len(cfg.Profiles) == 0 {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"authenticated via "+kind+" (stateless env credential) — nothing is stored to log out; unset "+kind+" to drop it")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no profiles configured; nothing to log out")
|
||||
}
|
||||
|
||||
@@ -131,14 +139,15 @@ func runLogout(opts *LogoutOptions, fopts *cmdutil.FormatOptions, f *cmdutil.Fac
|
||||
}
|
||||
for _, name := range targets {
|
||||
clearProfileSecrets(store, cfg.Profiles[name], name)
|
||||
delete(cfg.Profiles, name)
|
||||
}
|
||||
// If we removed the active profile, pick a remaining one (deterministic by
|
||||
// map order would be flaky - leave CurrentProfile empty so the next
|
||||
// invocation surfaces a clear "no current profile" error rather than
|
||||
// silently switching).
|
||||
if _, stillExists := cfg.Profiles[cfg.CurrentProfile]; !stillExists {
|
||||
cfg.CurrentProfile = ""
|
||||
// Keep the profile registered (its host stays) — only clear the
|
||||
// credential refs so it reads as logged-out and can be re-authed with
|
||||
// `auth login`. Deleting the profile entirely is `profile remove`'s job;
|
||||
// that clean logout(who)/remove(what) split matches gh / lark.
|
||||
p := cfg.Profiles[name]
|
||||
p.APIKeyRef = ""
|
||||
p.TokenRef = ""
|
||||
p.RefreshRef = ""
|
||||
cfg.Profiles[name] = p
|
||||
}
|
||||
if err := config.Save(cfg); err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "save config")
|
||||
|
||||
@@ -48,8 +48,12 @@ func TestLogout_CurrentProfile(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.CurrentProfile, "current_profile should clear when removed")
|
||||
assert.NotContains(t, cfg.Profiles, "prod")
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active profile stays selected — logout clears creds, not the profile")
|
||||
require.Contains(t, cfg.Profiles, "prod", "profile stays registered after logout")
|
||||
assert.Empty(t, cfg.Profiles["prod"].APIKeyRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].TokenRef, "credential ref cleared")
|
||||
assert.Empty(t, cfg.Profiles["prod"].RefreshRef, "credential ref cleared")
|
||||
assert.Equal(t, "https://prod", cfg.Profiles["prod"].Host, "host preserved for re-login")
|
||||
assert.Contains(t, cfg.Profiles, "staging", "non-target profile untouched")
|
||||
|
||||
// Secrets gone for the removed profile, kept for the survivor.
|
||||
@@ -81,7 +85,8 @@ func TestLogout_ActiveProfileViaOverride(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.NotContains(t, cfg.Profiles, "staging", "active profile (staging) is the target")
|
||||
require.Contains(t, cfg.Profiles, "staging", "target profile stays registered (creds cleared, not removed)")
|
||||
assert.Empty(t, cfg.Profiles["staging"].APIKeyRef, "target's credential ref cleared")
|
||||
assert.Contains(t, cfg.Profiles, "prod", "non-target profile untouched")
|
||||
}
|
||||
|
||||
@@ -98,8 +103,14 @@ func TestLogout_All(t *testing.T) {
|
||||
}
|
||||
require.NoError(t, runLogout(&LogoutOptions{All: true, Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, newLogoutFactory(t, cfg, store)))
|
||||
|
||||
assert.Empty(t, cfg.Profiles)
|
||||
assert.Empty(t, cfg.CurrentProfile)
|
||||
// --all clears every profile's credentials but keeps the profiles registered.
|
||||
require.NotEmpty(t, cfg.Profiles, "profiles stay registered after logout --all")
|
||||
for name, p := range cfg.Profiles {
|
||||
assert.Empty(t, p.APIKeyRef, "%s api-key ref cleared", name)
|
||||
assert.Empty(t, p.TokenRef, "%s token ref cleared", name)
|
||||
assert.Empty(t, p.RefreshRef, "%s refresh ref cleared", name)
|
||||
}
|
||||
assert.Equal(t, "prod", cfg.CurrentProfile, "active selection is preserved; logout clears creds, not the profile")
|
||||
}
|
||||
|
||||
func TestLogout_NoProfiles(t *testing.T) {
|
||||
|
||||
@@ -62,6 +62,10 @@ refresh semantic. Rotate the key in the server UI instead.`,
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
@@ -99,6 +103,10 @@ refresh semantic. Rotate the key in the server UI instead.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Renew the JWT access token for the active profile (override with the global --profile) using the stored refresh token. API-key profiles are rejected.",
|
||||
Output: "envelope.data has profile name that was refreshed",
|
||||
Examples: []string{
|
||||
"weknora auth refresh",
|
||||
"weknora --profile staging auth refresh",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -117,6 +125,10 @@ func runRefresh(ctx context.Context, opts *RefreshOptions, fopts *cmdutil.Format
|
||||
}
|
||||
name := cfg.CurrentProfile
|
||||
if name == "" {
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"authenticated via "+kind+" (stateless env credential): there is no stored JWT to refresh — env credentials are supplied fresh each call, so no refresh is needed")
|
||||
}
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"no active profile configured; run `weknora auth login` to set one up")
|
||||
}
|
||||
|
||||
+22
-1
@@ -2,6 +2,7 @@ package auth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
@@ -90,6 +91,16 @@ to see which mode each profile uses, and construct the matching HTTP header:
|
||||
}
|
||||
|
||||
func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
// Env credentials are the active credential on the headless path — `auth
|
||||
// token` must surface them (they ARE the token / api key) instead of
|
||||
// erroring on the absence of a stored profile.
|
||||
if active, kind := cmdutil.EnvCredential(); active {
|
||||
mode := ModeBearer
|
||||
if kind == "WEKNORA_API_KEY" {
|
||||
mode = ModeAPIKey
|
||||
}
|
||||
return emitToken(fopts, os.Getenv(kind), mode, "(env)")
|
||||
}
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -140,8 +151,18 @@ func runToken(f *cmdutil.Factory, fopts *cmdutil.FormatOptions) error {
|
||||
fmt.Sprintf("profile %q credential is empty in keyring; run `weknora auth login`", profileName))
|
||||
}
|
||||
|
||||
return emitToken(fopts, token, mode, profileName)
|
||||
}
|
||||
|
||||
// emitToken renders a resolved credential: the {token, mode, profile} envelope
|
||||
// under --format json, else the raw token on stdout (no trailing newline, for
|
||||
// clean $(weknora auth token) capture) with a TTY-only leak hint on stderr.
|
||||
func emitToken(fopts *cmdutil.FormatOptions, token, mode, profile string) error {
|
||||
if token == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "active credential is empty")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profileName}, nil)
|
||||
return fopts.Emit(iostreams.IO.Out, tokenResult{Token: token, Mode: mode, Profile: profile}, nil)
|
||||
}
|
||||
|
||||
// No trailing newline - clean $(weknora auth token) substitution.
|
||||
|
||||
@@ -124,6 +124,7 @@ func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DocID, "doc", "", "Parent document id (SDK knowledge_id) the chunks live under")
|
||||
_ = cmd.MarkFlagRequired("doc")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, chunkDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "chunk.delete")
|
||||
|
||||
+12
-8
@@ -19,7 +19,7 @@ const (
|
||||
defaultPageSize = 50
|
||||
maxPageSize = 1000
|
||||
defaultLimit = 50
|
||||
maxLimit = 1000
|
||||
maxLimit = 10000
|
||||
previewWidth = 80
|
||||
)
|
||||
|
||||
@@ -45,7 +45,7 @@ type ListOptions struct {
|
||||
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).
|
||||
// Limit caps the client-side accumulated slice (1..10000, default 50).
|
||||
// Default 50 chosen as domain-tuned for chunk enumeration (RAG debug).
|
||||
Limit int
|
||||
// AllPages walks server pages internally until total exhausted or
|
||||
@@ -65,7 +65,7 @@ For relevance-ranked retrieval (the RAG runtime surface), use
|
||||
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)
|
||||
input.invalid_argument --limit out of 1..10000 or --page-size out of 1..1000 (exit 5)
|
||||
resource.not_found no document with the given id (exit 4)
|
||||
|
||||
AI agents: prefer 'search chunks' for retrieval tasks. Use 'chunk list'
|
||||
@@ -106,15 +106,16 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
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 — client-side cap; meta.has_more reports truncation (1..1000)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultLimit, "Maximum results to return — client-side cap; meta.has_more/total_count report truncation (1..10000)")
|
||||
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.AddFormatFlag(cmd, chunkListFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List chunks of a specific document in stored order (admin/debug). Results come with meta.count; use --limit (1..1000) and --all-pages to paginate. Prefer 'search chunks' for RAG retrieval.",
|
||||
UsedFor: "List chunks of a specific document in stored order (admin/debug). Results come with meta.count; use --limit (1..10000) and --all-pages to paginate. Prefer 'search chunks' for RAG retrieval.",
|
||||
RequiredFlags: []string{"--doc"},
|
||||
Examples: []string{"weknora chunk list --doc doc_abc --format json", "weknora chunk list --doc doc_abc --all-pages --format json"},
|
||||
Output: "envelope.data is an array of Chunk objects with id, chunk_index, content, is_enabled; meta.count is the total returned",
|
||||
Output: "envelope.data is an array of Chunk objects with id, chunk_index, content, is_enabled; meta.count is the returned count, meta.total_count the document's full chunk count, meta.has_more true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -144,6 +145,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption
|
||||
}
|
||||
|
||||
var items []sdk.Chunk
|
||||
var serverTotal int64
|
||||
truncated := false
|
||||
if opts.AllPages {
|
||||
accum := make([]sdk.Chunk, 0)
|
||||
@@ -152,6 +154,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
accum = append(accum, chunks...)
|
||||
if len(accum) >= opts.Limit {
|
||||
accum = accum[:opts.Limit]
|
||||
@@ -164,10 +167,11 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption
|
||||
}
|
||||
items = accum
|
||||
} else {
|
||||
chunks, _, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
|
||||
chunks, total, err := svc.ListKnowledgeChunks(ctx, opts.DocID, 1, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list chunks for doc %s", opts.DocID)
|
||||
}
|
||||
serverTotal = total
|
||||
items = chunks
|
||||
}
|
||||
if items == nil {
|
||||
@@ -179,7 +183,7 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(int(serverTotal)), HasMore: truncated}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
|
||||
@@ -128,9 +128,35 @@ func TestList_AllPages_LimitTruncatesAccumulated(t *testing.T) {
|
||||
assert.Equal(t, []string{"c1", "c2", "c3"}, []string{got[0].ID, got[1].ID, got[2].ID})
|
||||
}
|
||||
|
||||
// TestList_JSON_EmitsTotalCount pins that chunk list surfaces the document's
|
||||
// full chunk count as meta.total_count (like doc/session/kb/model list), not
|
||||
// just the returned count — an agent must be able to tell truncation from
|
||||
// completeness.
|
||||
func TestList_JSON_EmitsTotalCount(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{
|
||||
pages: [][]sdk.Chunk{{{ID: "c1"}, {ID: "c2"}}},
|
||||
totals: []int64{7},
|
||||
errs: []error{nil},
|
||||
}
|
||||
opts := &ListOptions{DocID: "d1", Limit: 50, PageSize: 50}
|
||||
require.NoError(t, runList(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Count *int `json:"count"`
|
||||
TotalCount *int `json:"total_count"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
require.NotNil(t, env.Meta.TotalCount, "chunk list must emit meta.total_count")
|
||||
assert.Equal(t, 7, *env.Meta.TotalCount)
|
||||
require.NotNil(t, env.Meta.Count)
|
||||
assert.Equal(t, 2, *env.Meta.Count)
|
||||
}
|
||||
|
||||
func TestList_LimitInvalid(t *testing.T) {
|
||||
svc := &fakeListSvc{}
|
||||
for _, lim := range []int{0, -1, 1001} {
|
||||
for _, lim := range []int{0, -1, 10001} {
|
||||
err := runList(context.Background(), &ListOptions{DocID: "d", Limit: lim, PageSize: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc)
|
||||
require.Error(t, err, "expect error for --limit %d", lim)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
|
||||
@@ -81,6 +81,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, chunkViewFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one chunk's fields and content by id (scope-less; no --doc needed)",
|
||||
RequiredFlags: []string{"<chunk-id> (positional)"},
|
||||
|
||||
+18
-13
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -26,17 +27,12 @@ var docCreateFields = []string{
|
||||
// CreateOptions holds CLI flag values for `doc create`.
|
||||
type CreateOptions struct {
|
||||
Text string // --text (required): document text content (Markdown)
|
||||
Title string // --title: document title (preferred; matches `doc update --title`)
|
||||
Name string // --name: deprecated alias for --title
|
||||
Title string // --title: document title (matches `doc update --title`)
|
||||
TagID string // --tag-id: associate with a tag
|
||||
Channel string // --channel: ingestion-channel tag (default "api")
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// title returns the resolved document title, preferring --title over the
|
||||
// deprecated --name alias.
|
||||
func (o *CreateOptions) title() string { return cmp.Or(o.Title, o.Name) }
|
||||
|
||||
// CreateService is the narrow SDK surface for `doc create`.
|
||||
// *sdk.Client satisfies it.
|
||||
type CreateService interface {
|
||||
@@ -84,7 +80,7 @@ don't require a file upload or remote URL. KB resolution follows the standard
|
||||
Action: "doc.create",
|
||||
Args: map[string]any{
|
||||
"text": opts.Text,
|
||||
"title": opts.title(),
|
||||
"title": opts.Title,
|
||||
"kb": kbID,
|
||||
},
|
||||
}); handled {
|
||||
@@ -106,8 +102,6 @@ don't require a file upload or remote URL. KB resolution follows the standard
|
||||
cmdutil.AddKBFlag(cmd)
|
||||
cmd.Flags().StringVar(&opts.Text, "text", "", "Document text content in Markdown format (required)")
|
||||
cmd.Flags().StringVar(&opts.Title, "title", "", "Document title")
|
||||
cmd.Flags().StringVar(&opts.Name, "name", "", "Document title (deprecated: use --title)")
|
||||
_ = cmd.Flags().MarkDeprecated("name", "use --title instead")
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Tag id to associate with the new entry")
|
||||
cmd.Flags().StringVar(&opts.Channel, "channel", "", "Ingestion-channel tag recorded server-side (default \"api\")")
|
||||
_ = cmd.MarkFlagRequired("text")
|
||||
@@ -134,7 +128,7 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp
|
||||
return cmdutil.NewFlagError(fmt.Errorf("--text is required"))
|
||||
}
|
||||
req := &sdk.CreateManualKnowledgeRequest{
|
||||
Title: opts.title(),
|
||||
Title: opts.Title,
|
||||
Content: opts.Text,
|
||||
TagID: opts.TagID,
|
||||
Channel: cmp.Or(opts.Channel, uploadChannel),
|
||||
@@ -143,10 +137,18 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "create document")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, k, nil)
|
||||
// Inline-created docs land in parse_status=draft and are NOT auto-queued
|
||||
// for parsing (unlike `doc upload`), so they aren't searchable until
|
||||
// reparsed. Surface the next step so an agent doesn't `doc wait` into a
|
||||
// timeout or `search` into empty results.
|
||||
var meta *output.Meta
|
||||
if k.ParseStatus == "draft" {
|
||||
meta = &output.Meta{Hint: "document created in parse_status=draft (not yet indexed) — run `weknora doc reparse " + k.ID + "` to parse & make it searchable"}
|
||||
}
|
||||
displayed := opts.title()
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, k, meta)
|
||||
}
|
||||
displayed := opts.Title
|
||||
if displayed == "" {
|
||||
displayed = k.Title
|
||||
}
|
||||
@@ -154,5 +156,8 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp
|
||||
displayed = k.ID
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created %q (id: %s)\n", displayed, k.ID)
|
||||
if meta != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, " ⚠ %s\n", meta.Hint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -35,28 +35,21 @@ func (f *fakeCreateSvc) CreateManualKnowledge(
|
||||
return f.resp, f.err
|
||||
}
|
||||
|
||||
// TestCreate_TitlePreferredOverName: --title is the canonical flag; the
|
||||
// deprecated --name remains a working alias but --title wins when both are set.
|
||||
func TestCreate_TitlePreferredOverName(t *testing.T) {
|
||||
// TestCreate_TitleSetsRequestTitle: --title is the sole title flag and flows
|
||||
// straight into the create request.
|
||||
func TestCreate_TitleSetsRequestTitle(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d1"}}
|
||||
require.NoError(t, runCreate(context.Background(),
|
||||
&CreateOptions{Text: "x", Title: "FromTitle", Name: "FromName"},
|
||||
&CreateOptions{Text: "x", Title: "FromTitle"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb1"))
|
||||
assert.Equal(t, "FromTitle", svc.got.req.Title)
|
||||
|
||||
// --name alone still works (back-compat for the deprecated alias).
|
||||
svc2 := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "d2"}}
|
||||
require.NoError(t, runCreate(context.Background(),
|
||||
&CreateOptions{Text: "x", Name: "OnlyName"},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc2, "kb1"))
|
||||
assert.Equal(t, "OnlyName", svc2.got.req.Title)
|
||||
}
|
||||
|
||||
func TestCreate_Success_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_1", Title: "Sprint Notes"}}
|
||||
opts := &CreateOptions{Text: "# Sprint Notes\n\nAction items: ...", Name: "Sprint Notes"}
|
||||
opts := &CreateOptions{Text: "# Sprint Notes\n\nAction items: ...", Title: "Sprint Notes"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_xxx"))
|
||||
|
||||
assert.Equal(t, "kb_xxx", svc.got.kbID)
|
||||
@@ -110,7 +103,7 @@ func TestCreate_Channel_DefaultIsAPI(t *testing.T) {
|
||||
func TestCreate_JSON_Envelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.Knowledge{ID: "doc_manual_json", Title: "My Note"}}
|
||||
opts := &CreateOptions{Text: "# My Note", Name: "My Note"}
|
||||
opts := &CreateOptions{Text: "# My Note", Title: "My Note"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
require.NoError(t, runCreate(context.Background(), opts, fopts, svc, "kb_xxx"))
|
||||
|
||||
|
||||
@@ -79,11 +79,16 @@ stdout.`,
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.Output, "output", "O", "", `Output path; "-" for stdout. Defaults to the server-suggested filename.`)
|
||||
cmd.Flags().BoolVar(&opts.Clobber, "clobber", false, "Overwrite the output file if it already exists")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, downloadFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Download a document's bytes by id. Writes a file (or stdout with --output -).",
|
||||
RequiredFlags: []string{"<doc-id> (positional)"},
|
||||
Output: "with --format json (file output): envelope.data has path, bytes, filename; suppressed with --output - (raw bytes to stdout)",
|
||||
Examples: []string{
|
||||
"weknora doc download doc_abc --output ./manual.pdf",
|
||||
"weknora doc download doc_abc --output - > manual.pdf",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
+2
-2
@@ -106,7 +106,7 @@ backend storage order is not guaranteed and varies between deployments.`,
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmd.Flags().BoolVar(&opts.AllPages, "all-pages", false, "Walk all server pages until exhausted (or --limit hit)")
|
||||
cmd.Flags().StringVar(&opts.Status, "status", "", "Filter by parse status: pending | processing | completed | failed")
|
||||
cmd.Flags().StringVar(&opts.Keyword, "keyword", "", "Server-side substring match against title / file_name (case-sensitive)")
|
||||
cmd.Flags().StringVar(&opts.Keyword, "keyword", "", "Server-side substring match against title / file_name (case-insensitive)")
|
||||
cmd.Flags().StringVar(&opts.FileType, "file-type", "", `Filter by file extension (e.g. "pdf", "md")`)
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", `Filter by ingestion source (e.g. "api", "web")`)
|
||||
cmd.Flags().StringVar(&opts.TagID, "tag-id", "", "Filter by tag association")
|
||||
@@ -116,7 +116,7 @@ backend storage order is not guaranteed and varies between deployments.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List documents in the resolved knowledge base. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --status/--keyword to filter server-side.",
|
||||
Examples: []string{"weknora doc list --format json", "weknora doc list --all-pages --limit 200 --format json"},
|
||||
Output: "envelope.data is an array of Knowledge objects with id, title, file_name, parse_status; meta.count is the total returned; meta.total_count is the server-side total before client-side --limit truncation",
|
||||
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 is the server-side total before client-side --limit truncation; meta.has_more=true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -57,6 +57,7 @@ keeps its id; parsing restarts asynchronously, so follow with
|
||||
return runReparse(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, docReparseFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
|
||||
@@ -98,6 +98,7 @@ to the user first.`,
|
||||
}
|
||||
cmd.Flags().StringVar(&title, "title", "", "New title (omit to leave unchanged)")
|
||||
cmd.Flags().StringVar(&desc, "description", "", "New description (omit to leave unchanged)")
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.AddFormatFlag(cmd, docUpdateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "doc.update")
|
||||
|
||||
@@ -81,16 +81,11 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, fopts *cmdutil
|
||||
// uploaded captures per-path server results for successful uploads.
|
||||
// Populated by the RunBatch closure; read by the resultFn below.
|
||||
uploaded := make(map[string]uploadedFile, len(matches))
|
||||
var firstFailCode cmdutil.ErrorCode
|
||||
channel := cmp.Or(opts.Channel, uploadChannel)
|
||||
|
||||
outcomes, runErr := cmdutil.RunBatch(ctx, matches, func(ctx context.Context, p string) error {
|
||||
k, err := svc.CreateKnowledgeFromFile(ctx, kbID, p, meta, opts.EnableMultimodel, "", channel, nil)
|
||||
if err != nil {
|
||||
code := cmdutil.ClassifyHTTPError(err)
|
||||
if firstFailCode == "" {
|
||||
firstFailCode = code
|
||||
}
|
||||
// Per-file progress lines are human progress signal; suppress
|
||||
// under --format json so they don't precede the JSON object on stdout.
|
||||
if !fopts.WantsJSON() {
|
||||
@@ -133,9 +128,15 @@ func runUploadRecursive(ctx context.Context, opts *UploadOptions, fopts *cmdutil
|
||||
// carries per-file detail; without Silent the root error handler would
|
||||
// print to stderr in addition. ExitCode still walks Code so the typed
|
||||
// exit-code-by-class contract holds.
|
||||
code := firstFailCode
|
||||
if code == "" {
|
||||
code = cmdutil.ClassifyContextErr(ctx.Err())
|
||||
// Any per-file failure collapses to operation.failed (exit 1) — the
|
||||
// per-file batch envelope carries each file's typed error, which is the
|
||||
// authoritative signal (matches doc/session/chunk batch delete). A
|
||||
// cancelled / timed-out batch keeps its context class (124 / 130) so a
|
||||
// permanent partial failure (e.g. a duplicate) is never misreported as a
|
||||
// retryable 5xx that an agent would loop on.
|
||||
code := cmdutil.CodeOperationFailed
|
||||
if ctxErr := ctx.Err(); ctxErr != nil {
|
||||
code = cmdutil.ClassifyContextErr(ctxErr)
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: code,
|
||||
|
||||
@@ -109,9 +109,12 @@ func TestUploadRecursive_PartialFailure_Exits1(t *testing.T) {
|
||||
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
// CodeServerError preserves the 500 classification of the underlying
|
||||
// SDK error - the recursive wrapper just aggregates.
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
// Any per-file failure aggregates to operation.failed (exit 1), matching the
|
||||
// batch-delete contract — a partial failure (even a permanent one like a
|
||||
// duplicate, which classifies as server.error per-file) must not surface as
|
||||
// a retryable exit 7 an agent would loop on. Per-file codes live in the
|
||||
// batch envelope.
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "OK") // ok.pdf still succeeded
|
||||
@@ -267,5 +270,5 @@ func TestUploadRecursive_JSON_BatchEnvelope(t *testing.T) {
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.True(t, typed.Silent, "JSON-path partial failure must be Silent")
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, docViewFields...)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one document's metadata by id",
|
||||
RequiredFlags: []string{"<doc-id> (positional)"},
|
||||
|
||||
@@ -94,6 +94,7 @@ For fail-fast semantics, use shell composition:
|
||||
cmd.Flags().DurationVar(&opts.Timeout, "timeout", 10*time.Minute, "Max wait time before exiting 124")
|
||||
cmd.Flags().DurationVar(&opts.Interval, "interval", 2*time.Second, "Initial poll interval; exponential backoff capped at 15s + jitter")
|
||||
cmdutil.AddFormatFlag(cmd)
|
||||
cmdutil.AddIgnoredKBFlag(cmd)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "block until one or more documents reach a terminal parse state (completed or failed), or --timeout elapses",
|
||||
RequiredFlags: []string{"<doc-id>... (one or more positionals)"},
|
||||
@@ -234,6 +235,14 @@ func waitForDocs(ctx context.Context, ids []string, svc WaitService, opts WaitOp
|
||||
case "failed":
|
||||
addFailed(FailedDoc{ID: id, Message: doc.ErrorMessage})
|
||||
return
|
||||
case "draft":
|
||||
// draft = created but NOT queued for parsing (inline
|
||||
// `doc create` leaves docs here; file `doc upload`
|
||||
// auto-enqueues). It never progresses on its own, so
|
||||
// waiting would hang to the --timeout (124). Fail fast with
|
||||
// the exact unblock command instead of silently polling.
|
||||
addFailed(FailedDoc{ID: id, Message: "parse_status=draft: not queued for parsing — run `weknora doc reparse " + id + "` to index it"})
|
||||
return
|
||||
}
|
||||
|
||||
// Not yet terminal — sleep with jitter, then exp-backoff.
|
||||
|
||||
@@ -311,3 +311,30 @@ func TestDocWait_FailureError_IsSilent(t *testing.T) {
|
||||
t.Errorf("exit code = %d, want 1", cmdutil.ExitCode(err))
|
||||
}
|
||||
}
|
||||
|
||||
// TestWaitForDocs_DraftFailsFast pins that a document stuck in parse_status
|
||||
// "draft" (inline `doc create` leaves docs here; it never auto-progresses) is
|
||||
// failed fast with a reparse hint instead of polling until the --timeout.
|
||||
// Regression: `doc create` + `doc wait` used to hang to a 124 timeout.
|
||||
func TestWaitForDocs_DraftFailsFast(t *testing.T) {
|
||||
svc := newFakeKBSvc(map[string][]string{
|
||||
"doc_draft": {"draft", "draft", "draft"},
|
||||
})
|
||||
start := time.Now()
|
||||
res, _ := waitForDocs(context.Background(), []string{"doc_draft"}, svc, WaitOptions{
|
||||
Timeout: 5 * time.Second,
|
||||
Interval: 1 * time.Millisecond,
|
||||
})
|
||||
if len(res.Timeout) != 0 {
|
||||
t.Errorf("draft must NOT time out; got timeout=%v", res.Timeout)
|
||||
}
|
||||
if len(res.Failed) != 1 || res.Failed[0].ID != "doc_draft" {
|
||||
t.Fatalf("draft doc must be failed-fast; got failed=%v", res.Failed)
|
||||
}
|
||||
if !strings.Contains(res.Failed[0].Message, "reparse") {
|
||||
t.Errorf("draft failure message must point to reparse; got %q", res.Failed[0].Message)
|
||||
}
|
||||
if elapsed := time.Since(start); elapsed > time.Second {
|
||||
t.Errorf("draft must fail fast (well under timeout); took %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,6 +24,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -31,6 +32,7 @@ import (
|
||||
"github.com/Tencent/WeKnora/cli/internal/build"
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
@@ -396,15 +398,38 @@ func buildServices(f *cmdutil.Factory) (Services, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &realServices{f: f, host: resolveDoctorHost(cfg)}, nil
|
||||
}
|
||||
|
||||
// resolveDoctorHost picks the host base_url_reachable probes. Tiers 2 and 3
|
||||
// mirror the client builder (buildClientFromEnv) so doctor probes the host the
|
||||
// real commands actually connect to; tier 1 is a doctor-local test/dev knob:
|
||||
//
|
||||
// 1. WEKNORA_BASE_URL — doctor-only probe override (used by tests); NOT read
|
||||
// by the client builder, so setting it points doctor at a host no real
|
||||
// command uses. Kept for test/dev harnesses; leave unset in normal use.
|
||||
// 2. WEKNORA_HOST — when stateless env credentials (WEKNORA_TOKEN /
|
||||
// WEKNORA_API_KEY) are in effect, i.e. the headless agent path. Without
|
||||
// this, `WEKNORA_API_KEY=… WEKNORA_HOST=… weknora doctor` falsely reported
|
||||
// "no host configured" and exited 1 while every other command worked.
|
||||
// 3. active profile host — the configured default.
|
||||
func resolveDoctorHost(cfg *config.Config) string {
|
||||
host := ""
|
||||
if ctx, ok := cfg.Profiles[cfg.CurrentProfile]; ok {
|
||||
host = ctx.Host
|
||||
}
|
||||
// WEKNORA_BASE_URL still wins as a test/dev override; production reads host.
|
||||
// Env credentials authenticate via WEKNORA_HOST, bypassing the profile.
|
||||
// Honor it only when such creds are actually set, matching the client
|
||||
// builder (a bare WEKNORA_HOST without creds is ignored there too).
|
||||
if envActive, _ := cmdutil.EnvCredential(); envActive {
|
||||
if v := strings.TrimSpace(os.Getenv("WEKNORA_HOST")); v != "" {
|
||||
host = v
|
||||
}
|
||||
}
|
||||
if v := os.Getenv("WEKNORA_BASE_URL"); v != "" {
|
||||
host = v
|
||||
}
|
||||
return &realServices{f: f, host: host}, nil
|
||||
return host
|
||||
}
|
||||
|
||||
type realServices struct {
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/compat"
|
||||
"github.com/Tencent/WeKnora/cli/internal/config"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/secrets"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
@@ -507,3 +508,54 @@ func TestDoctor_RunE_WarnReturnsNil(t *testing.T) {
|
||||
t.Fatalf("setup error: expected Warned>=1, got %+v", r.Summary)
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveDoctorHost_EnvCredentials - doctor's base_url probe must honor
|
||||
// WEKNORA_HOST when stateless env credentials are in effect (the headless
|
||||
// agent path), mirroring buildClientFromEnv. Regression: doctor previously
|
||||
// read only the active profile host, so `WEKNORA_API_KEY=... WEKNORA_HOST=...
|
||||
// weknora doctor` reported "no host configured" and exited 1 while every
|
||||
// other command worked.
|
||||
func TestResolveDoctorHost_EnvCredentials(t *testing.T) {
|
||||
cfg := &config.Config{
|
||||
CurrentProfile: "prod",
|
||||
Profiles: map[string]config.Profile{"prod": {Host: "https://profile-host"}},
|
||||
}
|
||||
|
||||
t.Run("env creds + WEKNORA_HOST wins over profile", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_TOKEN", "")
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "https://env-host:8080")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://env-host:8080" {
|
||||
t.Errorf("want env host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("env creds without WEKNORA_HOST falls back to profile", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
|
||||
t.Errorf("want profile host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("no env creds ignores WEKNORA_HOST (matches client builder)", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_TOKEN", "")
|
||||
t.Setenv("WEKNORA_API_KEY", "")
|
||||
t.Setenv("WEKNORA_HOST", "https://should-be-ignored")
|
||||
t.Setenv("WEKNORA_BASE_URL", "")
|
||||
if got := resolveDoctorHost(cfg); got != "https://profile-host" {
|
||||
t.Errorf("want profile host, got %q", got)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("WEKNORA_BASE_URL test override always wins", func(t *testing.T) {
|
||||
t.Setenv("WEKNORA_API_KEY", "sk-test")
|
||||
t.Setenv("WEKNORA_HOST", "https://env-host")
|
||||
t.Setenv("WEKNORA_BASE_URL", "https://base-url-override")
|
||||
if got := resolveDoctorHost(cfg); got != "https://base-url-override" {
|
||||
t.Errorf("want base-url override, got %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -21,8 +21,8 @@ import (
|
||||
var dryRunExpectation = map[string]bool{
|
||||
// --- mutations: MUST have --dry-run ---
|
||||
"kb create": true, "kb update": true, "kb delete": true, "kb pin": true, "kb unpin": true,
|
||||
"kb init": true, // binds models to a KB (state change)
|
||||
"model create": true, "model delete": true,
|
||||
"kb config set": true, // binds models to a KB (state change)
|
||||
"model create": true, "model update": true, "model delete": true,
|
||||
"doc create": true, "doc upload": true, "doc fetch": true, "doc delete": true,
|
||||
"doc reparse": true, // re-triggers server-side parsing (a state change)
|
||||
"doc update": true, // edits title/description server-side
|
||||
@@ -55,21 +55,39 @@ var dryRunExpectation = map[string]bool{
|
||||
"doctor": false, "version": false,
|
||||
// generate / stream ops — the session-creation side effect is incidental,
|
||||
// not a CRUD write; a no-SDK-call preview would be meaningless.
|
||||
"chat": false, "session ask": false, "session continue-stream": false,
|
||||
"chat": false, "session ask": false, "session resume": false,
|
||||
// auth login VALIDATES credentials against the server and stores them; its
|
||||
// whole purpose is the server round-trip, which a side-effect-free dry-run
|
||||
// cannot exercise — so previewing it would be misleading. Exempt by design.
|
||||
"auth login": false,
|
||||
// long-running stdio server, not a one-shot command.
|
||||
"mcp serve": false,
|
||||
// offline read: enumerates tool metadata without any network call.
|
||||
"mcp tools list": false,
|
||||
// offline help topic: prints the static exit-code matrix.
|
||||
"exit-codes": false,
|
||||
// offline introspection: prints command contracts from the in-binary tree.
|
||||
"schema": false,
|
||||
}
|
||||
|
||||
// TestIdAddressedCommandsTolerateKB pins that the id-addressed read/wait
|
||||
// commands accept a (redundant, ignored) --kb flag, so an agent flowing from
|
||||
// `doc upload --kb X` into `doc wait <id> --kb X` doesn't hit exit 2.
|
||||
func TestIdAddressedCommandsTolerateKB(t *testing.T) {
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
for _, path := range [][]string{
|
||||
{"doc", "view"}, {"doc", "wait"}, {"doc", "download"},
|
||||
{"doc", "reparse"}, {"doc", "update"},
|
||||
{"chunk", "list"}, {"chunk", "view"}, {"chunk", "delete"},
|
||||
} {
|
||||
c, _, err := root.Find(path)
|
||||
if err != nil {
|
||||
t.Fatalf("find %v: %v", path, err)
|
||||
}
|
||||
if c.Flags().Lookup("kb") == nil {
|
||||
t.Errorf("`%s` must accept a --kb flag (ignored) so a carried-over --kb doesn't error", strings.Join(path, " "))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDryRunCoverageMatchesExpectation(t *testing.T) {
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
|
||||
|
||||
+15
-9
@@ -17,13 +17,17 @@ import (
|
||||
// aggregated by paging the doc list. Verb split with `kb status`:
|
||||
// status reads existing state cheaply, check actively verifies.
|
||||
type CheckResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
FailedCount int64 `json:"failed_count"` // always populated (no omitempty)
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
// RetrievalReady is false when no embedding model is bound — the KB can never
|
||||
// index/retrieve regardless of failed_count. Always emitted (no omitempty) so
|
||||
// an unconfigured KB is not reported as silently healthy.
|
||||
RetrievalReady bool `json:"retrieval_ready"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
FailedCount int64 `json:"failed_count"` // always populated (no omitempty)
|
||||
}
|
||||
|
||||
// CheckService is the narrow SDK surface needed for kb check.
|
||||
@@ -33,7 +37,7 @@ type CheckService interface {
|
||||
}
|
||||
|
||||
var kbCheckFields = []string{
|
||||
"id", "reachable", "knowledge_count", "chunk_count",
|
||||
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
|
||||
"is_processing", "processing_count", "failed_count",
|
||||
}
|
||||
|
||||
@@ -76,7 +80,7 @@ verification including failed-doc aggregation.`,
|
||||
UsedFor: "verify a knowledge base end-to-end: status plus failed-doc aggregation",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb check kb_abc"},
|
||||
Output: "envelope.data is {id, reachable, failed_count, ...}; deeper than `kb status`",
|
||||
Output: "envelope.data is {id, reachable, retrieval_ready, failed_count, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`); deeper than `kb status`",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -92,6 +96,7 @@ func runCheck(ctx context.Context, svc CheckService, id string) (*CheckResult, e
|
||||
res := &CheckResult{
|
||||
ID: kb.ID,
|
||||
Reachable: true,
|
||||
RetrievalReady: kb.EmbeddingModelID != "",
|
||||
KnowledgeCount: kb.KnowledgeCount,
|
||||
ChunkCount: kb.ChunkCount,
|
||||
IsProcessing: kb.IsProcessing,
|
||||
@@ -144,6 +149,7 @@ func writeCheckText(w io.Writer, res *CheckResult) error {
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
|
||||
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
|
||||
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
|
||||
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
|
||||
|
||||
@@ -39,7 +39,7 @@ func (f *fakeCheckSvc) ListKnowledgeWithFilter(_ context.Context, _ string, page
|
||||
|
||||
func TestRunCheck_AggregatesFailed(t *testing.T) {
|
||||
svc := &fakeCheckSvc{
|
||||
kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 5, ChunkCount: 20},
|
||||
kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 5, ChunkCount: 20, EmbeddingModelID: "emb_1"},
|
||||
failedDocs: []sdk.Knowledge{
|
||||
{ID: "d1", ParseStatus: "failed"},
|
||||
{ID: "d2", ParseStatus: "failed"},
|
||||
@@ -55,6 +55,9 @@ func TestRunCheck_AggregatesFailed(t *testing.T) {
|
||||
if !res.Reachable {
|
||||
t.Error("Reachable=false, want true")
|
||||
}
|
||||
if !res.RetrievalReady {
|
||||
t.Error("RetrievalReady=false, want true when an embedding model is bound")
|
||||
}
|
||||
if res.KnowledgeCount != 5 || res.ChunkCount != 20 {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
|
||||
+42
-20
@@ -12,25 +12,26 @@ import (
|
||||
)
|
||||
|
||||
// kbConfigFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb config`. Mirrors client.InitializationConfig.
|
||||
// `kb config`. Mirrors client.KBModelConfigView (secret-free — no api keys).
|
||||
var kbConfigFields = []string{
|
||||
"chat_model_id", "embedding_model_id", "rerank_model_id", "multimodal_id",
|
||||
"retrieval_ready", "embedding", "llm", "rerank", "multimodal",
|
||||
}
|
||||
|
||||
// ConfigService is the narrow SDK surface this command depends on.
|
||||
type ConfigService interface {
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.InitializationConfig, error)
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
|
||||
}
|
||||
|
||||
// NewCmdConfig builds `weknora kb config <kb-id>` — read-only inspection of a
|
||||
// knowledge base's model configuration (set it with `weknora kb init`).
|
||||
// knowledge base's model configuration. Write it with `weknora kb config set`.
|
||||
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "config <kb-id>",
|
||||
Short: "Show a knowledge base's model configuration",
|
||||
Long: `Show the model configuration bound to a knowledge base: chat, embedding,
|
||||
rerank, and multimodal model ids. An empty embedding_model_id means the KB is
|
||||
not yet usable for retrieval — configure it with 'weknora kb init'.`,
|
||||
Short: "Show a knowledge base's model configuration (set it with `config set`)",
|
||||
Long: `Show the model configuration bound to a knowledge base: embedding, llm
|
||||
(chat), rerank, and multimodal model names + source. retrieval_ready is false
|
||||
until an embedding model is bound — configure it with 'weknora kb config set'.
|
||||
Provider API keys are never shown.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
@@ -45,12 +46,15 @@ not yet usable for retrieval — configure it with 'weknora kb init'.`,
|
||||
return runConfig(c.Context(), fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
// `kb config` reads (this command's RunE); `kb config set` writes. Same
|
||||
// read/write pairing as mainstream config surfaces.
|
||||
cmd.AddCommand(newKBModelWriteCmd(f, "set <kb-id>", []string{"weknora", "kb", "config", "set"}))
|
||||
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "show a KB's model config (chat/embedding/rerank/multimodal model ids). Empty embedding_model_id => not retrieval-ready; run `weknora kb init`.",
|
||||
UsedFor: "show a KB's model config (embedding/llm/rerank/multimodal by name, secret-free). retrieval_ready=false => run `weknora kb config set`. Write config with `weknora kb config set`.",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb config kb_abc --jq .data.embedding_model_id"},
|
||||
Output: "envelope.data is {chat_model_id, embedding_model_id, rerank_model_id, multimodal_id}",
|
||||
Output: "envelope.data is {retrieval_ready, embedding{configured,model_name,source,dimension}, llm{...}, rerank{enabled,model_name}, multimodal{enabled}} — secret-free (no provider api keys)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -61,27 +65,45 @@ func runConfig(ctx context.Context, fopts *cmdutil.FormatOptions, svc ConfigServ
|
||||
return cmdutil.WrapHTTP(err, "get config for knowledge base %q", kbID)
|
||||
}
|
||||
if cfg == nil {
|
||||
cfg = &sdk.InitializationConfig{}
|
||||
cfg = &sdk.KBModelConfigView{}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, cfg, nil)
|
||||
}
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "%-11s %s\n", "EMBEDDING:", orNone(cfg.EmbeddingModelID))
|
||||
fmt.Fprintf(w, "%-11s %s\n", "CHAT:", orNone(cfg.ChatModelID))
|
||||
fmt.Fprintf(w, "%-11s %s\n", "RERANK:", orNone(cfg.RerankModelID))
|
||||
fmt.Fprintf(w, "%-11s %s\n", "MULTIMODAL:", orNone(cfg.MultimodalID))
|
||||
if cfg.EmbeddingModelID == "" {
|
||||
fmt.Fprintln(w, "\n(no embedding model set — run `weknora kb init <kb-id> --embedding-model <id>`)")
|
||||
fmt.Fprintf(w, "%-13s %v\n", "RETRIEVAL:", readyLabel(cfg.RetrievalReady))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "EMBEDDING:", slotLabel(cfg.Embedding))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "CHAT (LLM):", slotLabel(cfg.LLM))
|
||||
fmt.Fprintf(w, "%-13s %s\n", "RERANK:", rerankLabel(cfg.Rerank))
|
||||
fmt.Fprintf(w, "%-13s %v\n", "MULTIMODAL:", cfg.Multimodal.Enabled)
|
||||
if !cfg.RetrievalReady {
|
||||
fmt.Fprintln(w, "\n(not retrieval-ready — no embedding model; run `weknora kb config set <kb-id> --chat-model <id> --embedding-model <id>`)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func orNone(s string) string {
|
||||
if s == "" {
|
||||
func readyLabel(ready bool) string {
|
||||
if ready {
|
||||
return "ready"
|
||||
}
|
||||
return "NOT ready (no embedding model)"
|
||||
}
|
||||
|
||||
func slotLabel(s sdk.ModelSlotView) string {
|
||||
if !s.Configured {
|
||||
return "(unset)"
|
||||
}
|
||||
return s
|
||||
if s.Source != "" {
|
||||
return fmt.Sprintf("%s (%s)", s.ModelName, s.Source)
|
||||
}
|
||||
return s.ModelName
|
||||
}
|
||||
|
||||
func rerankLabel(r sdk.RerankSlotView) string {
|
||||
if !r.Enabled {
|
||||
return "(disabled)"
|
||||
}
|
||||
return r.ModelName
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements ConfigService.
|
||||
|
||||
@@ -12,43 +12,40 @@ import (
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// kbInitFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `kb init`. The result is the resulting InitializationConfig (read back).
|
||||
var kbInitFields = []string{
|
||||
"chat_model_id", "embedding_model_id", "rerank_model_id", "multimodal_id",
|
||||
}
|
||||
|
||||
type InitOptions struct {
|
||||
type ConfigSetOptions struct {
|
||||
ChatModel string
|
||||
EmbeddingModel string
|
||||
Yes bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// InitService is the narrow SDK surface this command depends on. SetKBModelConfig
|
||||
// ConfigSetService is the narrow SDK surface this command depends on. SetKBModelConfig
|
||||
// points the KB at already-registered models; GetInitializationConfig re-reads
|
||||
// the server's resulting state so the success envelope reflects what stuck.
|
||||
type InitService interface {
|
||||
type ConfigSetService interface {
|
||||
SetKBModelConfig(ctx context.Context, kbID string, cfg *sdk.KBModelConfig) error
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.InitializationConfig, error)
|
||||
GetInitializationConfig(ctx context.Context, kbID string) (*sdk.KBModelConfigView, error)
|
||||
}
|
||||
|
||||
// NewCmdInit builds `weknora kb init <kb-id>` — bind models to a knowledge base
|
||||
// so it becomes usable for retrieval and generation.
|
||||
func NewCmdInit(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &InitOptions{}
|
||||
// newKBModelWriteCmd builds the `kb config set` model-binding write command.
|
||||
// head is the argv prefix used for the risk action and retry_argv (weknora kb
|
||||
// config set).
|
||||
func newKBModelWriteCmd(f *cmdutil.Factory, use string, head []string) *cobra.Command {
|
||||
opts := &ConfigSetOptions{}
|
||||
action := strings.Join(head[1:], ".") // e.g. "kb.config.set"
|
||||
cmd := &cobra.Command{
|
||||
Use: "init <kb-id>",
|
||||
Short: "Configure a knowledge base's models (make it usable)",
|
||||
Use: use,
|
||||
Short: "Bind embedding + chat models to a knowledge base (make it usable)",
|
||||
Long: `Bind already-registered models to a knowledge base so it can embed, retrieve,
|
||||
and generate. Both --chat-model (LLM, used for generation/summary) and
|
||||
--embedding-model (used for retrieval) are required; register models first with
|
||||
'weknora model create' and discover ids with 'weknora model list'.
|
||||
|
||||
High-risk write: changing a KB's embedding model affects how its content is
|
||||
indexed and searched (and the server refuses once the KB has documents).
|
||||
Without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
(input.confirmation_required) without applying the change.`,
|
||||
indexed and searched (the server refuses to CHANGE it once the KB has
|
||||
documents; setting it on an unconfigured KB is allowed). Without -y/--yes in a
|
||||
non-TTY / JSON context it exits 10 (input.confirmation_required) without
|
||||
applying the change.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
@@ -60,11 +57,11 @@ Without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
kbID := args[0]
|
||||
// Validate required flags before the dry-run gate so --dry-run rejects
|
||||
// identically to the live path.
|
||||
if err := validateInitFlags(opts); err != nil {
|
||||
if err := validateConfigSetFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "kb.init",
|
||||
Action: action,
|
||||
Args: map[string]any{"kb": kbID, "chat_model": opts.ChatModel, "embedding_model": opts.EmbeddingModel},
|
||||
}); handled {
|
||||
return err
|
||||
@@ -74,8 +71,8 @@ Without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
return err
|
||||
}
|
||||
if err := cmdutil.ConfirmDestructive(f.Prompter(), opts.Yes, fopts.WantsJSON(),
|
||||
"configure", "knowledge base", kbID, "kb.init",
|
||||
cmdutil.BuildRetryArgv(c, []string{"weknora", "kb", "init", kbID}, "chat-model", "embedding-model", "format")); err != nil {
|
||||
"configure", "knowledge base", kbID, action,
|
||||
cmdutil.BuildRetryArgv(c, append(append([]string{}, head...), kbID), "chat-model", "embedding-model", "format")); err != nil {
|
||||
return err
|
||||
}
|
||||
// Resolve name-or-id for the model flags (a UUID passes through; a
|
||||
@@ -87,30 +84,30 @@ Without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runInit(c.Context(), opts, fopts, cli, kbID)
|
||||
return runConfigSet(c.Context(), opts, fopts, cli, kbID)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat / LLM model id or name for generation & summary (required) — see `weknora model list`")
|
||||
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name for retrieval (required) — see `weknora model list`")
|
||||
cmdutil.AddFormatFlag(cmd, kbInitFields...)
|
||||
cmdutil.AddFormatFlag(cmd, kbConfigFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "kb.init")
|
||||
cmdutil.SetRisk(cmd, action)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "bind models to a KB so it becomes usable. --chat-model and --embedding-model are required and accept a model id or name; discover them with `weknora model list`.",
|
||||
UsedFor: "bind models to a KB so it becomes retrieval-ready. --chat-model and --embedding-model are required and accept a model id or name; discover them with `weknora model list`. Read the result back with `weknora kb config`.",
|
||||
RequiredFlags: []string{"<kb-id> (positional)", "--chat-model", "--embedding-model"},
|
||||
Examples: []string{
|
||||
"weknora kb init kb_abc --chat-model model_llm --embedding-model model_emb -y",
|
||||
"weknora kb config set kb_abc --chat-model model_llm --embedding-model model_emb -y",
|
||||
},
|
||||
Output: "envelope.data is the resulting {chat_model_id, embedding_model_id, rerank_model_id, multimodal_id}",
|
||||
Output: "envelope.data is the resulting secret-free config view {retrieval_ready, embedding, llm, rerank, multimodal}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"The server refuses to change the embedding model of a KB that already has documents.",
|
||||
"The server refuses to CHANGE the embedding model of a KB that already has documents (setting it on an unconfigured KB is fine).",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func validateInitFlags(opts *InitOptions) error {
|
||||
func validateConfigSetFlags(opts *ConfigSetOptions) error {
|
||||
var missing []string
|
||||
if strings.TrimSpace(opts.ChatModel) == "" {
|
||||
missing = append(missing, "--chat-model")
|
||||
@@ -123,14 +120,13 @@ func validateInitFlags(opts *InitOptions) error {
|
||||
}
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputMissingFlag,
|
||||
Message: "kb init requires " + strings.Join(missing, " and "),
|
||||
Message: "kb config set requires " + strings.Join(missing, " and "),
|
||||
Hint: "discover model ids with `weknora model list` (or register one with `weknora model create`), then pass --chat-model <id> --embedding-model <id>",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
func runInit(ctx context.Context, opts *InitOptions, fopts *cmdutil.FormatOptions, svc InitService, kbID string) error {
|
||||
if err := validateInitFlags(opts); err != nil {
|
||||
func runConfigSet(ctx context.Context, opts *ConfigSetOptions, fopts *cmdutil.FormatOptions, svc ConfigSetService, kbID string) error {
|
||||
if err := validateConfigSetFlags(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cfg := &sdk.KBModelConfig{
|
||||
@@ -140,19 +136,31 @@ func runInit(ctx context.Context, opts *InitOptions, fopts *cmdutil.FormatOption
|
||||
if err := svc.SetKBModelConfig(ctx, kbID, cfg); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "configure knowledge base %q", kbID)
|
||||
}
|
||||
// Re-read the server's resulting state so the envelope reflects what stuck.
|
||||
// Re-read the server's resulting state (secret-free view) so the envelope
|
||||
// reflects what stuck — the same shape `kb config` returns.
|
||||
result, err := svc.GetInitializationConfig(ctx, kbID)
|
||||
if err != nil || result == nil {
|
||||
// The write succeeded; surface what we applied if the read-back failed.
|
||||
result = &sdk.InitializationConfig{ChatModelID: opts.ChatModel, EmbeddingModelID: opts.EmbeddingModel}
|
||||
result = &sdk.KBModelConfigView{
|
||||
RetrievalReady: opts.EmbeddingModel != "",
|
||||
Embedding: sdk.ModelSlotView{Configured: opts.EmbeddingModel != "", ModelName: opts.EmbeddingModel},
|
||||
LLM: sdk.ModelSlotView{Configured: opts.ChatModel != "", ModelName: opts.ChatModel},
|
||||
}
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, result, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Configured knowledge base %s (chat: %s, embedding: %s)\n",
|
||||
kbID, result.ChatModelID, result.EmbeddingModelID)
|
||||
kbID, orUnset(result.LLM.ModelName), orUnset(result.Embedding.ModelName))
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements InitService.
|
||||
var _ InitService = (*sdk.Client)(nil)
|
||||
func orUnset(s string) string {
|
||||
if s == "" {
|
||||
return "(unset)"
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements ConfigSetService.
|
||||
var _ ConfigSetService = (*sdk.Client)(nil)
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
type fakeInitSvc struct {
|
||||
gotKB string
|
||||
gotCfg *sdk.KBModelConfig
|
||||
result *sdk.InitializationConfig
|
||||
result *sdk.KBModelConfigView
|
||||
setErr error
|
||||
}
|
||||
|
||||
@@ -29,24 +29,25 @@ func (f *fakeInitSvc) SetKBModelConfig(_ context.Context, kbID string, cfg *sdk.
|
||||
return f.setErr
|
||||
}
|
||||
|
||||
func (f *fakeInitSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) {
|
||||
func (f *fakeInitSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
if f.result != nil {
|
||||
return f.result, nil
|
||||
}
|
||||
if f.gotCfg == nil {
|
||||
return &sdk.InitializationConfig{}, nil
|
||||
return &sdk.KBModelConfigView{}, nil
|
||||
}
|
||||
return &sdk.InitializationConfig{
|
||||
ChatModelID: f.gotCfg.LLMModelID,
|
||||
EmbeddingModelID: f.gotCfg.EmbeddingModelID,
|
||||
return &sdk.KBModelConfigView{
|
||||
RetrievalReady: f.gotCfg.EmbeddingModelID != "",
|
||||
Embedding: sdk.ModelSlotView{Configured: f.gotCfg.EmbeddingModelID != "", ModelName: f.gotCfg.EmbeddingModelID},
|
||||
LLM: sdk.ModelSlotView{Configured: f.gotCfg.LLMModelID != "", ModelName: f.gotCfg.LLMModelID},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func TestKBInit_AppliesAndEmitsResult(t *testing.T) {
|
||||
func TestKBConfigSet_AppliesAndEmitsResult(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeInitSvc{}
|
||||
opts := &InitOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runInit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
|
||||
assert.Equal(t, "kb_abc", svc.gotKB)
|
||||
require.NotNil(t, svc.gotCfg)
|
||||
@@ -54,46 +55,47 @@ func TestKBInit_AppliesAndEmitsResult(t *testing.T) {
|
||||
assert.Equal(t, "model_emb", svc.gotCfg.EmbeddingModelID)
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.InitializationConfig `json:"data"`
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_emb", env.Data.EmbeddingModelID)
|
||||
assert.Equal(t, "model_llm", env.Data.ChatModelID)
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
|
||||
assert.Equal(t, "model_llm", env.Data.LLM.ModelName)
|
||||
assert.True(t, env.Data.RetrievalReady)
|
||||
}
|
||||
|
||||
func TestKBInit_RequiresBothModels(t *testing.T) {
|
||||
func TestKBConfigSet_RequiresBothModels(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeInitSvc{}
|
||||
// Missing both.
|
||||
err := runInit(context.Background(), &InitOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
err := runConfigSet(context.Background(), &ConfigSetOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
var ce *cmdutil.Error
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Equal(t, cmdutil.CodeInputMissingFlag, ce.Code)
|
||||
assert.Equal(t, "", svc.gotKB, "must not call SetKBModelConfig when flags are missing")
|
||||
|
||||
// Missing just embedding.
|
||||
err = runInit(context.Background(), &InitOptions{ChatModel: "m"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
err = runConfigSet(context.Background(), &ConfigSetOptions{ChatModel: "m"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "kb_abc")
|
||||
require.ErrorAs(t, err, &ce)
|
||||
assert.Contains(t, ce.Message, "--embedding-model")
|
||||
}
|
||||
|
||||
func TestKBInit_WriteSucceedsReadbackFails(t *testing.T) {
|
||||
func TestKBConfigSet_WriteSucceedsReadbackFails(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc2 := &readbackErrSvc{fakeInitSvc: &fakeInitSvc{}}
|
||||
opts := &InitOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runInit(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc2, "kb_abc"))
|
||||
opts := &ConfigSetOptions{ChatModel: "model_llm", EmbeddingModel: "model_emb"}
|
||||
require.NoError(t, runConfigSet(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc2, "kb_abc"))
|
||||
var env struct {
|
||||
Data sdk.InitializationConfig `json:"data"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Equal(t, "model_emb", env.Data.EmbeddingModelID, "falls back to applied config when read-back fails")
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName, "falls back to applied config when read-back fails")
|
||||
}
|
||||
|
||||
type readbackErrSvc struct{ *fakeInitSvc }
|
||||
|
||||
func (s *readbackErrSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) {
|
||||
func (s *readbackErrSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
return nil, errors.New("read-back boom")
|
||||
}
|
||||
|
||||
@@ -116,13 +118,14 @@ func withRootKB(sub *cobra.Command, args ...string) *cobra.Command {
|
||||
return root
|
||||
}
|
||||
|
||||
func TestKBInit_RequiresConfirmation(t *testing.T) {
|
||||
func TestKBConfigSet_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
f := &cmdutil.Factory{
|
||||
Client: func() (*sdk.Client, error) { return nil, nil },
|
||||
Prompter: func() prompt.Prompter { return prompt.AgentPrompter{} },
|
||||
}
|
||||
root := withRootKB(NewCmdInit(f), "kb_abc", "--chat-model", "model_llm", "--embedding-model", "model_emb", "--format", "json")
|
||||
// Drive `kb config set` (the config parent routes to its `set` subcommand).
|
||||
root := withRootKB(NewCmdConfig(f), "set", "kb_abc", "--chat-model", "model_llm", "--embedding-model", "model_emb", "--format", "json")
|
||||
err := root.Execute()
|
||||
require.Error(t, err)
|
||||
var ce *cmdutil.Error
|
||||
@@ -131,4 +134,5 @@ func TestKBInit_RequiresConfirmation(t *testing.T) {
|
||||
assert.Equal(t, 10, cmdutil.ExitCode(err))
|
||||
assert.Contains(t, ce.RetryArgv, "-y")
|
||||
assert.Contains(t, ce.RetryArgv, "model_emb")
|
||||
assert.Contains(t, ce.RetryArgv, "set", "retry_argv should target `kb config set`")
|
||||
}
|
||||
+31
-11
@@ -14,37 +14,57 @@ import (
|
||||
)
|
||||
|
||||
type fakeConfigSvc struct {
|
||||
cfg *sdk.InitializationConfig
|
||||
cfg *sdk.KBModelConfigView
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeConfigSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.InitializationConfig, error) {
|
||||
func (f *fakeConfigSvc) GetInitializationConfig(_ context.Context, _ string) (*sdk.KBModelConfigView, error) {
|
||||
return f.cfg, f.err
|
||||
}
|
||||
|
||||
func TestKBConfig_EmitsConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: &sdk.InitializationConfig{EmbeddingModelID: "model_emb", ChatModelID: "model_chat"}}
|
||||
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
|
||||
RetrievalReady: true,
|
||||
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "model_emb", Source: "remote"},
|
||||
LLM: sdk.ModelSlotView{Configured: true, ModelName: "model_chat"},
|
||||
}}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.InitializationConfig `json:"data"`
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "model_emb", env.Data.EmbeddingModelID)
|
||||
assert.Equal(t, "model_chat", env.Data.ChatModelID)
|
||||
assert.Equal(t, "model_emb", env.Data.Embedding.ModelName)
|
||||
assert.Equal(t, "model_chat", env.Data.LLM.ModelName)
|
||||
assert.True(t, env.Data.RetrievalReady)
|
||||
}
|
||||
|
||||
// TestKBConfig_NilConfig: a nil server config (KB not yet initialized) emits an
|
||||
// empty object, not a crash.
|
||||
// TestKBConfig_SecretFree: the view type has no apiKey/baseUrl field, so the
|
||||
// JSON output can never carry provider credentials — the CLI never echoes the
|
||||
// keys the server returns for the web config form.
|
||||
func TestKBConfig_SecretFree(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: &sdk.KBModelConfigView{
|
||||
Embedding: sdk.ModelSlotView{Configured: true, ModelName: "e", Source: "remote"},
|
||||
}}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
assert.NotContains(t, out.String(), "apiKey")
|
||||
assert.NotContains(t, out.String(), "api_key")
|
||||
assert.NotContains(t, out.String(), "baseUrl")
|
||||
}
|
||||
|
||||
// TestKBConfig_NilConfig: a nil server config (KB not yet initialized) emits
|
||||
// retrieval_ready:false, not a crash.
|
||||
func TestKBConfig_NilConfig(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeConfigSvc{cfg: nil}
|
||||
require.NoError(t, runConfig(context.Background(), &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "kb_abc"))
|
||||
var env struct {
|
||||
Data sdk.InitializationConfig `json:"data"`
|
||||
Data sdk.KBModelConfigView `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Empty(t, env.Data.EmbeddingModelID)
|
||||
assert.False(t, env.Data.RetrievalReady)
|
||||
assert.Empty(t, env.Data.Embedding.ModelName)
|
||||
}
|
||||
|
||||
+28
-5
@@ -9,6 +9,7 @@ import (
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type CreateOptions struct {
|
||||
Name string
|
||||
Description string
|
||||
EmbeddingModel string
|
||||
ChatModel string
|
||||
StorageProvider string
|
||||
DryRun bool
|
||||
}
|
||||
@@ -84,16 +86,23 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
// --embedding-model accepts a model id or name (a UUID passes
|
||||
// through; a name resolves among Embedding models). Configuring a
|
||||
// KB's models fully is `weknora kb init`; this just pre-sets the
|
||||
// KB's models fully is `weknora kb config set`; this just pre-sets the
|
||||
// embedding model at creation.
|
||||
if opts.EmbeddingModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.EmbeddingModel, "Embedding"); err != nil {
|
||||
return err
|
||||
}
|
||||
// --chat-model (id or name) pre-sets the KB's LLM at creation, so a
|
||||
// KB can be born retrieval-ready in one step. Full model config
|
||||
// (rerank / multimodal) is still `weknora kb config set`.
|
||||
if opts.ChatModel, err = cmdutil.ResolveModelRef(c.Context(), cli, opts.ChatModel, "KnowledgeQA"); err != nil {
|
||||
return err
|
||||
}
|
||||
return runCreate(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "Knowledge base description (optional)")
|
||||
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name (optional; configure models fully with `weknora kb init`)")
|
||||
cmd.Flags().StringVar(&opts.EmbeddingModel, "embedding-model", "", "Embedding model id or name (optional; makes the KB retrieval-ready at creation)")
|
||||
cmd.Flags().StringVar(&opts.ChatModel, "chat-model", "", "Chat/LLM model id or name (optional; pre-set the KB's answer model at creation)")
|
||||
cmd.Flags().StringVar(&opts.StorageProvider, "storage-provider", "",
|
||||
"Storage provider for documents in this KB: "+strings.Join(storageProviderValues, " | ")+" (optional; server default when unset)")
|
||||
cmdutil.AddFormatFlag(cmd, kbCreateFields...)
|
||||
@@ -103,10 +112,10 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
RequiredFlags: []string{"<name> (positional)"},
|
||||
Examples: []string{
|
||||
`weknora kb create "Eng Docs"`,
|
||||
`weknora kb create "Eng Docs" --description "engineering knowledge base"`,
|
||||
`weknora kb create "Eng Docs" --embedding-model text-embedding-3-small --chat-model gpt-4o-mini # retrieval-ready in one step`,
|
||||
`weknora kb create "Eng Docs" --jq .data.id # capture id to chain into doc upload --kb`,
|
||||
},
|
||||
Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id",
|
||||
Output: "envelope.data is the created KnowledgeBase object with id, name, type, embedding_model_id, summary_model_id",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -125,6 +134,9 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp
|
||||
if opts.EmbeddingModel != "" {
|
||||
req.EmbeddingModelID = opts.EmbeddingModel
|
||||
}
|
||||
if opts.ChatModel != "" {
|
||||
req.SummaryModelID = opts.ChatModel
|
||||
}
|
||||
if opts.StorageProvider != "" {
|
||||
canonSP, err := cmdutil.ValidateEnum("storage-provider", opts.StorageProvider, storageProviderValues)
|
||||
if err != nil {
|
||||
@@ -138,9 +150,20 @@ func runCreate(ctx context.Context, opts *CreateOptions, fopts *cmdutil.FormatOp
|
||||
return cmdutil.WrapHTTP(err, "create knowledge base")
|
||||
}
|
||||
|
||||
// A KB with no embedding model can hold documents but never index/retrieve
|
||||
// them — surface the next step at the point of creation instead of leaving
|
||||
// the agent to discover a silent-draft KB via a later empty search.
|
||||
var meta *output.Meta
|
||||
if created.EmbeddingModelID == "" {
|
||||
meta = &output.Meta{Hint: "retrieval_ready=false: no embedding model bound. Uploaded docs will not be searchable until you run `weknora kb config set " + created.ID + " --embedding-model <id> --chat-model <id>` (create the KB with --embedding-model/--chat-model to skip this step)."}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, created, nil)
|
||||
return fopts.Emit(iostreams.IO.Out, created, meta)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Created knowledge base %q (id: %s)\n", created.Name, created.ID)
|
||||
if meta != nil {
|
||||
fmt.Fprintf(iostreams.IO.Out, "⚠ %s\n", meta.Hint)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -66,6 +66,50 @@ func TestCreate_Success_OmitsEmbeddingModelWhenEmpty(t *testing.T) {
|
||||
assert.Equal(t, "", svc.got.EmbeddingModelID, "embedding-model unset ⇒ empty in request")
|
||||
}
|
||||
|
||||
// A KB created without an embedding model can never retrieve; the create result
|
||||
// must hand the agent the fix (kb config set) instead of a silent unusable KB.
|
||||
func TestCreate_HintsWhenNoEmbeddingModel(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
|
||||
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta struct {
|
||||
Hint string `json:"hint"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
assert.Contains(t, env.Meta.Hint, "kb config set", "unconfigured KB must hint the retrieval-readiness fix")
|
||||
}
|
||||
|
||||
// A retrieval-ready KB (embedding model bound) carries no such hint — no noise.
|
||||
func TestCreate_NoHintWhenEmbeddingModelBound(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n", EmbeddingModelID: "emb_1"}}
|
||||
require.NoError(t, runCreate(context.Background(), &CreateOptions{Name: "n", EmbeddingModel: "emb_1"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
Meta *struct {
|
||||
Hint string `json:"hint"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env))
|
||||
if env.Meta != nil {
|
||||
assert.Empty(t, env.Meta.Hint, "retrieval-ready KB must not emit a readiness hint")
|
||||
}
|
||||
}
|
||||
|
||||
// TestCreate_ChatModelSetsSummaryModelID: --chat-model rides the create request
|
||||
// as summary_model_id, so a KB can be born retrieval-ready in one step.
|
||||
func TestCreate_ChatModelSetsSummaryModelID(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{resp: &sdk.KnowledgeBase{ID: "kb_x", Name: "n"}}
|
||||
opts := &CreateOptions{Name: "n", EmbeddingModel: "emb_x", ChatModel: "chat_x"}
|
||||
require.NoError(t, runCreate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
|
||||
require.NotNil(t, svc.got)
|
||||
assert.Equal(t, "emb_x", svc.got.EmbeddingModelID)
|
||||
assert.Equal(t, "chat_x", svc.got.SummaryModelID, "--chat-model must set summary_model_id on the create request")
|
||||
}
|
||||
|
||||
func TestCreate_NameRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeCreateSvc{}
|
||||
|
||||
@@ -81,6 +81,7 @@ exactly to guard against unintended deletes.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete a knowledge base and all its contents",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Output: "envelope.data is {id, deleted:true}",
|
||||
Examples: []string{
|
||||
"weknora kb delete kb_abc -y",
|
||||
"weknora kb delete kb_abc -y --format json",
|
||||
|
||||
@@ -120,6 +120,7 @@ to the user first.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "update a knowledge base's name or description",
|
||||
RequiredFlags: []string{"<kb-id> (positional)", "--name or --description (at least one)"},
|
||||
Output: "envelope.data is the updated KnowledgeBase object (id, name, description)",
|
||||
Examples: []string{
|
||||
"weknora kb update kb_abc --name \"New Name\" -y",
|
||||
"weknora kb update kb_abc --description \"Updated desc\" --format json -y",
|
||||
|
||||
+1
-2
@@ -25,7 +25,6 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdUnpin(f))
|
||||
cmd.AddCommand(NewCmdStatus(f))
|
||||
cmd.AddCommand(NewCmdCheck(f))
|
||||
cmd.AddCommand(NewCmdConfig(f))
|
||||
cmd.AddCommand(NewCmdInit(f))
|
||||
cmd.AddCommand(NewCmdConfig(f)) // `config` also hosts the `config set` write subcommand
|
||||
return cmd
|
||||
}
|
||||
|
||||
+22
-8
@@ -16,12 +16,16 @@ import (
|
||||
// Shallow read only: 1 HTTP call, no failed-doc aggregation.
|
||||
// For deep verification including failed_count, use `kb check <id>`.
|
||||
type StatusResult struct {
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
ID string `json:"id"`
|
||||
Reachable bool `json:"reachable"`
|
||||
// RetrievalReady is false when the KB has no embedding model bound — it can
|
||||
// never index or retrieve until `kb config set` runs. Always emitted (no
|
||||
// omitempty) so a not-ready KB is visible, not silently green.
|
||||
RetrievalReady bool `json:"retrieval_ready"`
|
||||
KnowledgeCount int64 `json:"knowledge_count,omitempty"`
|
||||
ChunkCount int64 `json:"chunk_count,omitempty"`
|
||||
IsProcessing bool `json:"is_processing,omitempty"`
|
||||
ProcessingCount int64 `json:"processing_count,omitempty"`
|
||||
}
|
||||
|
||||
// StatusService is the narrow SDK surface needed for kb status.
|
||||
@@ -30,7 +34,7 @@ type StatusService interface {
|
||||
}
|
||||
|
||||
var kbStatusFields = []string{
|
||||
"id", "reachable", "knowledge_count", "chunk_count",
|
||||
"id", "reachable", "retrieval_ready", "knowledge_count", "chunk_count",
|
||||
"is_processing", "processing_count",
|
||||
}
|
||||
|
||||
@@ -73,7 +77,7 @@ For full metadata (config / pinned / tenant), use 'weknora kb view <id>'.`,
|
||||
UsedFor: "shallow health probe of a knowledge base (one HTTP call): reachability, no failed-doc aggregation",
|
||||
RequiredFlags: []string{"<kb-id> (positional)"},
|
||||
Examples: []string{"weknora kb status kb_abc"},
|
||||
Output: "envelope.data is {id, reachable, ...}; use `kb check` for deep failed-doc aggregation",
|
||||
Output: "envelope.data is {id, reachable, retrieval_ready, ...}; retrieval_ready=false means no embedding model is bound (run `kb config set`), so the KB cannot index/retrieve; use `kb check` for deep failed-doc aggregation",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
@@ -89,6 +93,7 @@ func runStatus(ctx context.Context, svc StatusService, id string) (*StatusResult
|
||||
return &StatusResult{
|
||||
ID: kb.ID,
|
||||
Reachable: true,
|
||||
RetrievalReady: kb.EmbeddingModelID != "",
|
||||
KnowledgeCount: kb.KnowledgeCount,
|
||||
ChunkCount: kb.ChunkCount,
|
||||
IsProcessing: kb.IsProcessing,
|
||||
@@ -115,11 +120,20 @@ func writeStatusText(w io.Writer, res *StatusResult) error {
|
||||
if !res.Reachable {
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintf(w, "Retrieval: %v%s\n", res.RetrievalReady, retrievalHint(res.RetrievalReady))
|
||||
fmt.Fprintf(w, "Knowledge: %d\n", res.KnowledgeCount)
|
||||
fmt.Fprintf(w, "Chunks: %d\n", res.ChunkCount)
|
||||
fmt.Fprintf(w, "Processing: %v (%d active)\n", res.IsProcessing, res.ProcessingCount)
|
||||
return nil
|
||||
}
|
||||
|
||||
// retrievalHint annotates a not-ready KB in text output with the fix.
|
||||
func retrievalHint(ready bool) string {
|
||||
if ready {
|
||||
return ""
|
||||
}
|
||||
return " ← no embedding model bound; run `weknora kb config set <id>`"
|
||||
}
|
||||
|
||||
// compile-time check: SDK client satisfies StatusService.
|
||||
var _ StatusService = (*sdk.Client)(nil)
|
||||
|
||||
@@ -26,11 +26,12 @@ func (f *fakeStatusSvc) GetKnowledgeBase(_ context.Context, id string) (*sdk.Kno
|
||||
|
||||
func TestRunStatus_ShallowFields(t *testing.T) {
|
||||
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{
|
||||
ID: "kb_x",
|
||||
KnowledgeCount: 42,
|
||||
ChunkCount: 100,
|
||||
IsProcessing: true,
|
||||
ProcessingCount: 3,
|
||||
ID: "kb_x",
|
||||
KnowledgeCount: 42,
|
||||
ChunkCount: 100,
|
||||
IsProcessing: true,
|
||||
ProcessingCount: 3,
|
||||
EmbeddingModelID: "emb_1",
|
||||
}}
|
||||
res, err := runStatus(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
@@ -42,6 +43,22 @@ func TestRunStatus_ShallowFields(t *testing.T) {
|
||||
if res.KnowledgeCount != 42 || res.ChunkCount != 100 || res.ProcessingCount != 3 || !res.IsProcessing {
|
||||
t.Errorf("got %+v", res)
|
||||
}
|
||||
if !res.RetrievalReady {
|
||||
t.Error("RetrievalReady=false, want true when an embedding model is bound")
|
||||
}
|
||||
}
|
||||
|
||||
// A KB with no embedding model can never retrieve — the health probe must say
|
||||
// so (retrieval_ready=false), not report a silent all-green status.
|
||||
func TestRunStatus_RetrievalNotReadyWithoutEmbeddingModel(t *testing.T) {
|
||||
svc := &fakeStatusSvc{kb: &sdk.KnowledgeBase{ID: "kb_x", KnowledgeCount: 1}}
|
||||
res, err := runStatus(context.Background(), svc, "kb_x")
|
||||
if err != nil {
|
||||
t.Fatalf("runStatus: %v", err)
|
||||
}
|
||||
if res.RetrievalReady {
|
||||
t.Error("RetrievalReady=true, want false when no embedding model is bound")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunStatus_Unreachable(t *testing.T) {
|
||||
|
||||
@@ -163,7 +163,15 @@ func resolveProfile(f *cmdutil.Factory) (string, error) {
|
||||
return "", err
|
||||
}
|
||||
if cfg.CurrentProfile == "" {
|
||||
return "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated, "no active profile; run `weknora auth login` first")
|
||||
// `link` binds a directory to a profile+KB, so it needs a configured
|
||||
// profile — env credentials (WEKNORA_API_KEY) alone have no profile to
|
||||
// record. Point at profile setup (not `auth login`, which loops with no
|
||||
// profile) and name the headless alternative so an env-cred agent isn't
|
||||
// stranded on a misleading hint.
|
||||
return "", cmdutil.NewError(cmdutil.CodeAuthUnauthenticated,
|
||||
"`link` records an active profile, but none is configured").
|
||||
WithHint("register one with `weknora profile add <name> --host <url> --use`; for a headless (WEKNORA_API_KEY) workflow, skip `link` and pass --kb per command or set WEKNORA_KB_ID").
|
||||
WithRetryArgv([]string{"weknora", "profile", "add", "--help"})
|
||||
}
|
||||
return cfg.CurrentProfile, nil
|
||||
}
|
||||
|
||||
@@ -79,6 +79,9 @@ is present anywhere in the parent chain.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Remove the .weknora/project.yaml KB binding from the current directory tree. No flags required; walks up from cwd to find the link.",
|
||||
Output: "envelope.data has project_link_path of the removed file",
|
||||
Examples: []string{
|
||||
"weknora unlink",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ Consult your MCP client's documentation for the exact config-file location.`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "run weknora as a long-lived MCP (Model Context Protocol) server over stdio for an IDE/host agent",
|
||||
Output: "no stdout payload (JSON-RPC 2.0 protocol traffic); logs go to stderr",
|
||||
Examples: []string{
|
||||
"weknora mcp serve",
|
||||
},
|
||||
Warnings: []string{
|
||||
"this is a long-running stdio server, not a one-shot command — register it in your MCP client (command: weknora, args: [mcp, serve])",
|
||||
"exits with auth.unauthenticated at startup if no profile is configured",
|
||||
|
||||
@@ -76,7 +76,7 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
|
||||
`weknora message search "deploy steps"`,
|
||||
`weknora message search "deploy steps" --session sess_abc --limit 5`,
|
||||
},
|
||||
Output: "envelope.data is an array of grouped results (request_id, session_id, query_content, answer_content, score); meta.total_count is the server-side total. --mode accepts keyword | vector | hybrid (server default: hybrid)",
|
||||
Output: "envelope.data is an array of grouped results (request_id, session_id, query_content, answer_content, score); meta.count is the returned count, meta.total_count is the server-side total. --mode accepts keyword | vector | hybrid (server default: hybrid)",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -85,7 +85,7 @@ func NewCmdCreate(f *cmdutil.Factory) *cobra.Command {
|
||||
Use: "create <name>",
|
||||
Short: "Register a model (embedding / rerank / chat / VLLM / ASR)",
|
||||
Long: `Register a model on the server so it can back a knowledge base's embedding /
|
||||
summary config (see 'weknora kb init') or an agent (--model).
|
||||
summary config (see 'weknora kb config set') or an agent (--model).
|
||||
|
||||
<name> is the model name as the provider knows it (e.g. "nomic-embed-text",
|
||||
"gpt-4o", "qwen2"). --type and --source are required.
|
||||
@@ -178,7 +178,7 @@ else goes through repeatable --param key=value.`,
|
||||
cmdutil.AddFormatFlag(cmd, modelCreateFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "register a model (embedding/rerank/chat/VLLM/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb init` / `agent create --model`.",
|
||||
UsedFor: "register a model (embedding/rerank/chat/VLLM/ASR) so a KB or agent can use it; capture .data.id to pass to `weknora kb config set` / `agent create --model`.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--type", "--source (local|remote)", "--provider (when --source remote)"},
|
||||
Examples: []string{
|
||||
`weknora model create nomic-embed-text --type Embedding --source local --dimension 768 # Ollama (server pulls it)`,
|
||||
|
||||
+39
-2
@@ -40,6 +40,10 @@ type ListOptions struct {
|
||||
// …), matched case-insensitively. Empty shows everything.
|
||||
Type string
|
||||
Source string
|
||||
// Limit caps the returned slice client-side (applied after --type/--source
|
||||
// filtering and sort). The ListModels SDK is unpaginated, so the CLI holds
|
||||
// the true total and reports meta.total_count/has_more when --limit drops any.
|
||||
Limit int
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
@@ -62,6 +66,11 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
// Validate static input before building the client so a bad --limit
|
||||
// returns input.invalid_argument (exit 5), not an auth error (exit 3).
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -71,6 +80,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.Type, "type", "", "Only show models of this type (Embedding, Rerank, KnowledgeQA, VLLM, ASR)")
|
||||
cmd.Flags().StringVar(&opts.Source, "source", "", "Only show models from this provider (local, remote, openai, aliyun, …)")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum results to return — client-side cap; meta.has_more/total_count report the full size (1..10000)")
|
||||
cmdutil.AddFormatFlag(cmd, modelListFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "discover model ids for `agent create --model` and a KB's embedding/summary model",
|
||||
@@ -79,12 +89,28 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
"weknora model list --type KnowledgeQA --format json",
|
||||
"weknora model list --source local",
|
||||
},
|
||||
Output: "envelope.data is an array of Model objects (id, name, display_name, type, source, is_default); narrow it with --type / --source",
|
||||
Output: "envelope.data is an array of Model objects (id, name, display_name, type, source, is_default); narrow it with --type / --source; meta.count is the returned count, meta.total_count is the full set and meta.has_more=true means --limit truncated it",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --limit. Called from RunE before the client is built
|
||||
// (so a bad value surfaces as exit 5, not an auth error) and at runList's top
|
||||
// for direct callers; idempotent.
|
||||
func validateListOpts(opts *ListOptions) error {
|
||||
if opts.Limit < 1 || opts.Limit > 10000 {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..10000, got %d", opts.Limit),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOptions, svc ListService) error {
|
||||
if err := validateListOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := cmdutil.ValidateEnum("type", opts.Type, modelTypeValues); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -121,8 +147,19 @@ func runList(ctx context.Context, opts *ListOptions, fopts *cmdutil.FormatOption
|
||||
return modelLabel(items[i]) < modelLabel(items[j])
|
||||
})
|
||||
|
||||
// Client-side --limit cap. The ListModels SDK is unpaginated, so the CLI
|
||||
// holds the true total and can tell the caller whether --limit dropped any:
|
||||
// total_count is the full (post-filter) count, has_more flags truncation.
|
||||
total := len(items)
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, items, &output.Meta{Count: output.IntPtr(len(items))})
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(total)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
|
||||
@@ -27,7 +27,7 @@ func TestModelList_Text(t *testing.T) {
|
||||
{ID: "m1", DisplayName: "GPT-X", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI, IsDefault: true},
|
||||
{ID: "m2", Name: "bge", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
@@ -46,7 +46,7 @@ func TestModelList_TypeFilter(t *testing.T) {
|
||||
{ID: "m1", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m2", Type: sdk.ModelTypeEmbedding},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{Type: "embedding"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
if err := runList(context.Background(), &ListOptions{Type: "embedding", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
@@ -69,7 +69,7 @@ func TestModelList_TypeFilter(t *testing.T) {
|
||||
func TestModelList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: nil}
|
||||
if err := runList(context.Background(), &ListOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no models)") {
|
||||
@@ -80,7 +80,7 @@ func TestModelList_Empty(t *testing.T) {
|
||||
func TestModelList_EmptyAfterFilter(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{{ID: "m1", Type: sdk.ModelTypeEmbedding}}}
|
||||
if err := runList(context.Background(), &ListOptions{Type: "Rerank"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
if err := runList(context.Background(), &ListOptions{Type: "Rerank", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "(no models match the filter)") {
|
||||
@@ -95,7 +95,7 @@ func TestModelList_SourceFilter(t *testing.T) {
|
||||
{ID: "m1", Type: sdk.ModelTypeEmbedding, Source: sdk.ModelSourceLocal},
|
||||
{ID: "m2", Type: sdk.ModelTypeKnowledgeQA, Source: sdk.ModelSourceOpenAI},
|
||||
}}
|
||||
if err := runList(context.Background(), &ListOptions{Source: "OpenAI"}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
if err := runList(context.Background(), &ListOptions{Source: "OpenAI", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
@@ -113,8 +113,8 @@ func TestModelList_SourceFilter(t *testing.T) {
|
||||
// (input.invalid_argument) instead of silently returning an empty set.
|
||||
func TestModelList_InvalidEnum(t *testing.T) {
|
||||
for _, tc := range []struct{ name string; opts ListOptions }{
|
||||
{"type", ListOptions{Type: "bogus"}},
|
||||
{"source", ListOptions{Source: "bogus"}},
|
||||
{"type", ListOptions{Type: "bogus", Limit: 30}},
|
||||
{"source", ListOptions{Source: "bogus", Limit: 30}},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
@@ -127,3 +127,44 @@ func TestModelList_InvalidEnum(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_Limit_TruncatesAndSignals verifies --limit caps model list
|
||||
// output and reports meta.total_count/has_more like every other list command.
|
||||
// Regression: model list had no --limit, so an agent could not cap output or
|
||||
// tell whether it saw the full set.
|
||||
func TestModelList_Limit_TruncatesAndSignals(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListSvc{models: []sdk.Model{
|
||||
{ID: "m1", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m2", Type: sdk.ModelTypeKnowledgeQA},
|
||||
{ID: "m3", Type: sdk.ModelTypeKnowledgeQA},
|
||||
}}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
if err := runList(context.Background(), &ListOptions{Limit: 2}, fopts, svc); err != nil {
|
||||
t.Fatalf("runList: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if n := strings.Count(got, `"id":"m`); n != 2 {
|
||||
t.Errorf("--limit 2 should slice to 2 models, got %d in:\n%s", n, got)
|
||||
}
|
||||
if !strings.Contains(got, `"has_more":true`) {
|
||||
t.Errorf("truncated model list must set has_more:true; got:\n%s", got)
|
||||
}
|
||||
if !strings.Contains(got, `"total_count":3`) {
|
||||
t.Errorf("truncated model list must report total_count:3; got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelList_BadLimit_Rejected verifies an out-of-range --limit is exit-5
|
||||
// typed validation, consistent with kb/session list.
|
||||
func TestModelList_BadLimit_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(), &ListOptions{Limit: 99999}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &fakeListSvc{})
|
||||
if err == nil {
|
||||
t.Fatal("--limit 99999 must be rejected")
|
||||
}
|
||||
var e *cmdutil.Error
|
||||
if !errors.As(err, &e) || e.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("want input.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ import (
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "model",
|
||||
Short: "Manage models (list / view / create / delete)",
|
||||
Short: "Manage models (list / view / create / update / delete)",
|
||||
Long: `List, inspect, register, and delete the models configured on the server. Use
|
||||
the model id to back a knowledge base's embedding / summary config ('weknora kb
|
||||
init') or an agent ('weknora agent create --model <id>').`,
|
||||
@@ -29,6 +29,7 @@ init') or an agent ('weknora agent create --model <id>').`,
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdCreate(f))
|
||||
cmd.AddCommand(NewCmdUpdate(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -0,0 +1,189 @@
|
||||
package modelcmd
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// UpdateOptions captures the surgical flag state for `model update`. Per-flag
|
||||
// *Set bits distinguish "" (clear) from unset, matching agent/doc update.
|
||||
type UpdateOptions struct {
|
||||
DisplayName string
|
||||
Description string
|
||||
BaseURL string
|
||||
APIKeyStdin bool
|
||||
Params []string
|
||||
Default bool
|
||||
DryRun bool
|
||||
StdinReader io.Reader
|
||||
flags modelUpdateFlags
|
||||
}
|
||||
|
||||
type modelUpdateFlags struct{ displayName, description, baseURL, def bool }
|
||||
|
||||
// UpdateService is the narrow SDK surface. UpdateModel is a full PUT, so the
|
||||
// fetch (GetModel) is mandatory — without the baseline, any field not touched
|
||||
// by a flag would clobber to its zero value.
|
||||
type UpdateService interface {
|
||||
GetModel(ctx context.Context, id string) (*sdk.Model, error)
|
||||
UpdateModel(ctx context.Context, id string, req *sdk.UpdateModelRequest) (*sdk.Model, error)
|
||||
}
|
||||
|
||||
// NewCmdUpdate builds `weknora model update <model-id>` — update a registered
|
||||
// model in place (id preserved), so KBs / agents referencing it keep working.
|
||||
func NewCmdUpdate(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &UpdateOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "update <model-id>",
|
||||
Short: "Update a model in place (rotate key, base URL, display name, default)",
|
||||
Long: `Update a registered model WITHOUT changing its id, so KBs / agents that
|
||||
reference it keep working (unlike delete + re-create, which orphans references).
|
||||
Rotate the provider key with --api-key-stdin, or change --base-url,
|
||||
--display-name, --description, extra --param entries, or --default. A model's
|
||||
type and source are immutable — register a new model to change them.
|
||||
|
||||
Reversible write: without -y/--yes in a non-TTY / JSON context it exits 10
|
||||
(input.confirmation_required) without applying the change.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
id := args[0]
|
||||
opts.flags.displayName = c.Flags().Changed("display-name")
|
||||
opts.flags.description = c.Flags().Changed("description")
|
||||
opts.flags.baseURL = c.Flags().Changed("base-url")
|
||||
opts.flags.def = c.Flags().Changed("default")
|
||||
if !modelUpdateHasFlag(opts) {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "model update requires at least one flag",
|
||||
Hint: "pass e.g. --display-name, --base-url, --api-key-stdin, --param, or --default",
|
||||
}
|
||||
}
|
||||
params, err := parseParams(opts.Params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "model.update",
|
||||
Args: map[string]any{"model": id, "display_name": opts.DisplayName, "base_url": opts.BaseURL, "default": opts.Default, "rotate_api_key": opts.APIKeyStdin, "param_count": len(params)},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
yes, _ := c.Flags().GetBool("yes")
|
||||
// --api-key-stdin / --param excluded from retry_argv (stdin secret /
|
||||
// repeatable), matching agent update's multi-value exclusions.
|
||||
retry := cmdutil.BuildRetryArgv(c, []string{"weknora", "model", "update", id},
|
||||
"display-name", "description", "base-url", "default", "format")
|
||||
if err := cmdutil.ConfirmWrite(f.Prompter(), yes, fopts.WantsJSON(), "update", "model", id, "model.update", retry); err != nil {
|
||||
return err
|
||||
}
|
||||
if opts.StdinReader == nil {
|
||||
opts.StdinReader = iostreams.IO.In
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runUpdate(c.Context(), opts, fopts, cli, id, params)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVar(&opts.DisplayName, "display-name", "", "New human-friendly name")
|
||||
cmd.Flags().StringVar(&opts.Description, "description", "", "New description")
|
||||
cmd.Flags().StringVar(&opts.BaseURL, "base-url", "", "New model API base URL")
|
||||
cmd.Flags().BoolVar(&opts.APIKeyStdin, "api-key-stdin", false, "Rotate the provider API key, read from stdin (kept out of argv / history)")
|
||||
cmd.Flags().StringArrayVar(&opts.Params, "param", nil, "Set an extra provider parameter as key=value, repeatable (value parsed as JSON)")
|
||||
cmd.Flags().BoolVar(&opts.Default, "default", false, "Mark this the default model for its type (--default=false to unset)")
|
||||
cmdutil.AddFormatFlag(cmd, modelListFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetWriteRisk(cmd, "model.update")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "update a registered model IN PLACE (id preserved, so KB/agent references keep working): rotate --api-key-stdin, change --base-url / --display-name / --description / --param, or set --default. Type and source are immutable.",
|
||||
RequiredFlags: []string{"<model-id> (positional)", "at least one update flag"},
|
||||
Examples: []string{
|
||||
`printf '%s' "$NEW_KEY" | weknora model update mdl_abc --api-key-stdin -y`,
|
||||
`weknora model update mdl_abc --base-url https://api.example.com/v1 -y`,
|
||||
`weknora model update mdl_abc --default -y`,
|
||||
},
|
||||
Output: "envelope.data is the updated Model object (id preserved; provider api key never echoed)",
|
||||
Warnings: []string{
|
||||
"Reversible write: requires explicit approval (exit 10 / input.confirmation_required) unless -y; never auto-add -y.",
|
||||
"Server-side this is an admin operation; a non-admin credential gets auth.forbidden (exit 3).",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func modelUpdateHasFlag(o *UpdateOptions) bool {
|
||||
return o.flags.displayName || o.flags.description || o.flags.baseURL || o.flags.def ||
|
||||
o.APIKeyStdin || len(o.Params) > 0
|
||||
}
|
||||
|
||||
func runUpdate(ctx context.Context, opts *UpdateOptions, fopts *cmdutil.FormatOptions, svc UpdateService, id string, params map[string]any) error {
|
||||
// Fetch-then-update: UpdateModel is a full PUT, so start from the server's
|
||||
// current state and overlay only what the user changed.
|
||||
cur, err := svc.GetModel(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "fetch model %s", id)
|
||||
}
|
||||
merged := sdk.ModelParameters{}
|
||||
for k, v := range cur.Parameters {
|
||||
merged[k] = v
|
||||
}
|
||||
for k, v := range params {
|
||||
merged[k] = v
|
||||
}
|
||||
if opts.flags.baseURL {
|
||||
merged["base_url"] = opts.BaseURL
|
||||
}
|
||||
if opts.APIKeyStdin {
|
||||
key, err := readStdinTrimmed(opts.StdinReader)
|
||||
if err != nil {
|
||||
return cmdutil.Wrapf(cmdutil.CodeLocalFileIO, err, "read API key from stdin")
|
||||
}
|
||||
if key == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputMissingFlag, "--api-key-stdin requires the key piped to stdin")
|
||||
}
|
||||
merged["api_key"] = key
|
||||
}
|
||||
|
||||
req := &sdk.UpdateModelRequest{
|
||||
Name: cur.Name,
|
||||
DisplayName: cur.DisplayName,
|
||||
Description: cur.Description,
|
||||
Parameters: merged,
|
||||
IsDefault: cur.IsDefault,
|
||||
}
|
||||
if opts.flags.displayName {
|
||||
req.DisplayName = opts.DisplayName
|
||||
}
|
||||
if opts.flags.description {
|
||||
req.Description = opts.Description
|
||||
}
|
||||
if opts.flags.def {
|
||||
req.IsDefault = opts.Default
|
||||
}
|
||||
|
||||
updated, err := svc.UpdateModel(ctx, id, req)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "update model %s", id)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, updated, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Updated model %q (id: %s)\n", updated.Name, updated.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: the production SDK client implements UpdateService.
|
||||
var _ UpdateService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,81 @@
|
||||
package modelcmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeUpdateSvc scripts GetModel (fetch baseline) and captures the
|
||||
// UpdateModelRequest so tests can assert the surgical overlay.
|
||||
type fakeUpdateSvc struct {
|
||||
cur *sdk.Model
|
||||
gotReq *sdk.UpdateModelRequest
|
||||
gotID string
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) GetModel(_ context.Context, _ string) (*sdk.Model, error) {
|
||||
return f.cur, nil
|
||||
}
|
||||
|
||||
func (f *fakeUpdateSvc) UpdateModel(_ context.Context, id string, req *sdk.UpdateModelRequest) (*sdk.Model, error) {
|
||||
f.gotID = id
|
||||
f.gotReq = req
|
||||
return &sdk.Model{ID: id, Name: req.Name, DisplayName: req.DisplayName, Parameters: req.Parameters, IsDefault: req.IsDefault}, nil
|
||||
}
|
||||
|
||||
func baseModel() *sdk.Model {
|
||||
return &sdk.Model{
|
||||
ID: "mdl_x", Name: "keep-name", DisplayName: "old", Description: "olddesc",
|
||||
Parameters: sdk.ModelParameters{"base_url": "http://old", "api_key": "SECRET-OLD", "provider": "generic"},
|
||||
IsDefault: false,
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelUpdate_SurgicalOverlay: only touched fields change; the rest (name,
|
||||
// description, existing params) round-trip from the fetched baseline.
|
||||
func TestModelUpdate_SurgicalOverlay(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{cur: baseModel()}
|
||||
opts := &UpdateOptions{DisplayName: "new-display", flags: modelUpdateFlags{displayName: true}}
|
||||
require.NoError(t, runUpdate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "mdl_x", nil))
|
||||
|
||||
require.NotNil(t, svc.gotReq)
|
||||
assert.Equal(t, "mdl_x", svc.gotID, "id preserved (in-place update)")
|
||||
assert.Equal(t, "new-display", svc.gotReq.DisplayName, "display-name overlaid")
|
||||
assert.Equal(t, "keep-name", svc.gotReq.Name, "untouched name round-trips")
|
||||
assert.Equal(t, "olddesc", svc.gotReq.Description, "untouched description round-trips")
|
||||
assert.Equal(t, "http://old", svc.gotReq.Parameters["base_url"], "untouched params round-trip")
|
||||
assert.Equal(t, "SECRET-OLD", svc.gotReq.Parameters["api_key"], "existing key preserved when not rotating")
|
||||
}
|
||||
|
||||
// TestModelUpdate_RotateKeyAndBaseURL: --api-key-stdin + --base-url overlay the
|
||||
// parameters map; the new key comes from stdin, never argv.
|
||||
func TestModelUpdate_RotateKeyAndBaseURL(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeUpdateSvc{cur: baseModel()}
|
||||
opts := &UpdateOptions{
|
||||
BaseURL: "http://new", APIKeyStdin: true,
|
||||
StdinReader: strings.NewReader("NEW-KEY\n"),
|
||||
flags: modelUpdateFlags{baseURL: true},
|
||||
}
|
||||
require.NoError(t, runUpdate(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "mdl_x", nil))
|
||||
assert.Equal(t, "http://new", svc.gotReq.Parameters["base_url"])
|
||||
assert.Equal(t, "NEW-KEY", svc.gotReq.Parameters["api_key"], "rotated key read from stdin")
|
||||
}
|
||||
|
||||
// TestModelUpdate_RequiresAtLeastOneFlag: a bare `model update <id>` is rejected
|
||||
// before any network call, matching agent update.
|
||||
func TestModelUpdate_RequiresAtLeastOneFlag(t *testing.T) {
|
||||
assert.False(t, modelUpdateHasFlag(&UpdateOptions{}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{flags: modelUpdateFlags{displayName: true}}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{APIKeyStdin: true}))
|
||||
assert.True(t, modelUpdateHasFlag(&UpdateOptions{Params: []string{"k=v"}}))
|
||||
}
|
||||
@@ -103,6 +103,9 @@ adds leave the current profile untouched unless --use is passed.`,
|
||||
UsedFor: "Register a new profile (connection target) with a name and host URL. Does not store credentials; make it active (--use, or `profile use <n>`) and run `auth login` afterwards to authenticate.",
|
||||
RequiredFlags: []string{"<name> (positional)", "--host"},
|
||||
Output: "envelope.data has name, host, user, current",
|
||||
Examples: []string{
|
||||
"weknora profile add prod --host https://kb.example.com --use",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -90,6 +90,7 @@ in scripted / --format json invocations (exit code 10; see cli/README.md).`,
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "remove a named profile and its stored credentials",
|
||||
RequiredFlags: []string{"<name> (positional)"},
|
||||
Output: "envelope.data is {name, removed:true, was_current}",
|
||||
Examples: []string{
|
||||
"weknora profile remove staging",
|
||||
"weknora profile remove production -y",
|
||||
|
||||
+1
-1
@@ -238,7 +238,7 @@ func addGlobalFlags(cmd *cobra.Command) {
|
||||
// instead of being rejected as "unknown flag" exit 2 by cobra. Commands
|
||||
// that don't produce JSON output (e.g. `completion bash`) ignore the flag
|
||||
// rather than error — the unified agent contract is worth the trade.
|
||||
pf.String("format", "", "Output format: text | json | ndjson (default: json)")
|
||||
pf.String("format", "", "Output format: text | json | ndjson (default: json; env: WEKNORA_FORMAT)")
|
||||
pf.StringP("jq", "q", "", "Filter JSON output using a jq `expression` (requires --format json|ndjson)")
|
||||
}
|
||||
|
||||
|
||||
@@ -105,6 +105,16 @@ human help prose.`,
|
||||
// Returns a typed input.unknown_subcommand error (with did-you-mean) when the
|
||||
// path does not resolve to a real command.
|
||||
func resolveSchemaTarget(root *cobra.Command, args []string) (*cobra.Command, error) {
|
||||
// Tolerate the quoted multi-word form the no-arg `schema` index prints as a
|
||||
// command label (e.g. `schema "agent create"`): re-split each arg on
|
||||
// whitespace so an agent can paste a label verbatim and resolve it the same
|
||||
// as `schema agent create`.
|
||||
flat := make([]string, 0, len(args))
|
||||
for _, a := range args {
|
||||
flat = append(flat, strings.Fields(a)...)
|
||||
}
|
||||
args = flat
|
||||
|
||||
target, rest, err := root.Find(args)
|
||||
// Find returns root (with the args unconsumed) when nothing matched; a
|
||||
// fully-resolved leaf returns itself with its positional args as rest.
|
||||
|
||||
@@ -68,6 +68,25 @@ func TestSchema_SingleCommand(t *testing.T) {
|
||||
assert.False(t, flagNames["profile"], "inherited global flags must be excluded")
|
||||
}
|
||||
|
||||
// TestSchema_QuotedMultiWordArg: the no-arg `schema` index prints command
|
||||
// labels like "agent create"; an agent that pastes that label back as a single
|
||||
// quoted arg (`schema "agent create"`) must resolve the same as two tokens,
|
||||
// not fail with unknown_subcommand.
|
||||
func TestSchema_QuotedMultiWordArg(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
root := NewRootCmd(cmdutil.New())
|
||||
root.SetArgs([]string{"schema", "agent create", "--format", "json"})
|
||||
require.NoError(t, root.Execute(), "got %q", out.String())
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data commandSchema `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "got %q", out.String())
|
||||
assert.True(t, env.OK)
|
||||
assert.Equal(t, "agent create", env.Data.Command)
|
||||
}
|
||||
|
||||
// TestSchema_SurfacesRisk: a destructive command exposes its risk annotation,
|
||||
// so an agent can discover confirmation-gating without running the command.
|
||||
func TestSchema_SurfacesRisk(t *testing.T) {
|
||||
|
||||
@@ -158,7 +158,7 @@ func runChunks(ctx context.Context, opts *ChunksOptions, fopts *cmdutil.FormatOp
|
||||
if results == nil {
|
||||
results = []*sdk.SearchResult{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(results)), HasMore: truncated}
|
||||
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)
|
||||
|
||||
@@ -61,7 +61,7 @@ type DocsSearchService interface {
|
||||
|
||||
// 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-sensitive LIKE
|
||||
// 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{}
|
||||
@@ -72,11 +72,9 @@ func NewCmdDocs(f *cmdutil.Factory) *cobra.Command {
|
||||
keyword filter (matched against title / file_name). Useful for finding a
|
||||
specific upload to download or delete by id.
|
||||
|
||||
The query is a case-sensitive server-side LIKE filter (the server runs
|
||||
` + "`LIKE %keyword%`" + ` against title and file_name). For case-insensitive
|
||||
matching, lower-case the query yourself, e.g.
|
||||
` + "`weknora search docs \"$(printf %s YOUR_QUERY | tr 'A-Z' 'a-z')\"`" + `, or
|
||||
fall back to ` + "`weknora api`" + ` with a custom filter.
|
||||
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.`,
|
||||
@@ -123,7 +121,7 @@ reached or the KB is exhausted. Pass --all-pages=false to stop after one page.`,
|
||||
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.has_more=true if more matched than --limit",
|
||||
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
|
||||
}
|
||||
@@ -142,11 +140,13 @@ func runDocsSearch(ctx context.Context, opts *DocsSearchOptions, fopts *cmdutil.
|
||||
// --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.
|
||||
@@ -172,7 +172,7 @@ done:
|
||||
if matches == nil {
|
||||
matches = []sdk.Knowledge{}
|
||||
}
|
||||
meta := &output.Meta{Count: output.IntPtr(len(matches)), HasMore: truncated}
|
||||
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 {
|
||||
|
||||
@@ -115,6 +115,29 @@ func TestDocsSearch_JSON(t *testing.T) {
|
||||
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")}
|
||||
|
||||
@@ -32,3 +32,16 @@ func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
|
||||
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>`."
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ the caller to thread follow-ups.
|
||||
|
||||
AI agents: this is the primary entrypoint for invoking custom agents.
|
||||
The 'weknora agent' subtree handles CRUD only (list / view / create /
|
||||
edit / delete / status / check).
|
||||
update / delete / status / check).
|
||||
|
||||
Modes:
|
||||
--format json (default): one JSON envelope with answer events
|
||||
|
||||
@@ -47,6 +47,9 @@ func (s *scriptedAskSvc) AgentQAStreamWithRequest(_ context.Context, sessionID s
|
||||
func answerEvent(content string) *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeAnswer, Content: content}
|
||||
}
|
||||
// doneEvent is the stream's terminal frame. The real server ends an agent
|
||||
// stream with a `complete` event (it also sets Done=true on intermediate
|
||||
// frames), so the terminal is modeled as complete, not a bare answer+done.
|
||||
func doneEvent() *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeComplete, Done: true}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List chat sessions for the active profile. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --since to filter by recency (e.g. 7d).",
|
||||
Examples: []string{"weknora session list --format json", "weknora session list --all-pages --since 7d --format json"},
|
||||
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the total returned; meta.total_count is the server-side total before --since filtering",
|
||||
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the returned count; meta.total_count is the server-side total before --since filtering; meta.has_more=true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// continue_stream.go implements `weknora session continue-stream` —
|
||||
// resume.go implements `weknora session resume` —
|
||||
// re-attach to an SSE event buffer for an in-progress or already-completed
|
||||
// assistant message under a known session_id.
|
||||
//
|
||||
@@ -23,44 +23,42 @@ package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"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/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// continueStreamFields enumerates the NDJSON init-event + raw SDK event
|
||||
// resumeFields enumerates the NDJSON init-event + raw SDK event
|
||||
// vocabulary surfaced for `--format json` / `--format ndjson` discovery.
|
||||
var continueStreamFields = []string{
|
||||
var resumeFields = []string{
|
||||
"session_id", "message_id",
|
||||
// SDK StreamResponse fields (pass-through): id, response_type, content,
|
||||
// done, knowledge_references, assistant_message_id, session_id,
|
||||
// tool_calls, data
|
||||
}
|
||||
|
||||
// ContinueStreamOptions captures `session continue-stream` flag/arg state.
|
||||
type ContinueStreamOptions struct {
|
||||
// ResumeOptions captures `session resume` flag/arg state.
|
||||
type ResumeOptions struct {
|
||||
SessionID string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// ContinueStreamService is the narrow SDK surface this command depends on.
|
||||
// ResumeService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it; tests substitute a fake. Compile-time check
|
||||
// at the bottom of this file.
|
||||
type ContinueStreamService interface {
|
||||
type ResumeService interface {
|
||||
ContinueStream(ctx context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error
|
||||
}
|
||||
|
||||
// NewCmdContinueStream builds `weknora session continue-stream <session-id> --message <id>`.
|
||||
func NewCmdContinueStream(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ContinueStreamOptions{}
|
||||
// NewCmdResume builds `weknora session resume <session-id> --message <id>`.
|
||||
func NewCmdResume(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ResumeOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "continue-stream <session-id>",
|
||||
Use: "resume <session-id>",
|
||||
Short: "Resume an SSE event stream for an in-progress or completed session message",
|
||||
Long: `Re-attach to the SSE event buffer for an assistant message under a known session.
|
||||
|
||||
@@ -92,8 +90,8 @@ regardless of --format value. The operator use case (incident response,
|
||||
debugging) always wants the raw event log; there is no human-text rendering.
|
||||
--format json and --format ndjson behave identically here; --format text is
|
||||
silently treated as NDJSON.`,
|
||||
Example: ` weknora session continue-stream sess_xyz --message msg_abc
|
||||
weknora session continue-stream sess_xyz -m msg_abc --format ndjson`,
|
||||
Example: ` weknora session resume sess_xyz --message msg_abc
|
||||
weknora session resume sess_xyz -m msg_abc --format ndjson`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.SessionID = args[0]
|
||||
@@ -106,38 +104,39 @@ silently treated as NDJSON.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runContinueStream(c.Context(), opts, fopts, cli)
|
||||
return runResume(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.MessageID, "message", "m", "",
|
||||
"Assistant message ID to resume (from the init or agent_query event of the original stream)")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
cmdutil.AddFormatFlag(cmd, continueStreamFields...)
|
||||
cmdutil.AddFormatFlag(cmd, resumeFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Resume an SSE event stream for an in-progress or completed assistant message. Produces an NDJSON event stream: init line (session_id, message_id) then raw SDK StreamResponse events.",
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from prior init / agent_query event)"},
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (persisted assistant message id — get it from `weknora message list --session <id>`; a live stream's assistant_message_id is not resumable once the message persists)"},
|
||||
Examples: []string{
|
||||
"weknora session continue-stream sess_xyz --message msg_abc --format json",
|
||||
"# Network-blip recovery: replay with same session_id + message_id from the original 'session ask' init event",
|
||||
"weknora session resume sess_xyz --message msg_abc --format json",
|
||||
"# Get the message id from: weknora message list --session <session-id> (the persisted assistant message)",
|
||||
},
|
||||
Output: "NDJSON stream: {type:init, session_id, message_id, profile} then SDK StreamResponse events (response_type, content, done, knowledge_references, assistant_message_id, ...)",
|
||||
Warnings: []string{
|
||||
"Server replays from event 0 (NOT cursor-from-disconnect). Agents that already consumed events on the original stream MUST dedupe by message_id + event hash to avoid double-processing.",
|
||||
"Buffer TTL: redis mode 1h hardcoded; memory mode = process lifetime. After expiry the CLI returns local.sse_stream_aborted.",
|
||||
"Output is always NDJSON (an event stream, not an envelope): --jq does not apply and --format text/json/ndjson behave identically here — parse the event lines yourself.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runContinueStream is the testable core: validate, dispatch the resume, and
|
||||
// runResume is the testable core: validate, dispatch the resume, and
|
||||
// route the NDJSON stream. Returns a typed error.
|
||||
//
|
||||
// Always emits NDJSON: a buffered envelope makes no sense for a streaming
|
||||
// command, and continue-stream has no human-text use case (operators reach
|
||||
// command, and resume has no human-text use case (operators reach
|
||||
// for it during incident response / debugging, which always wants the raw
|
||||
// event log). --format text is therefore treated identically to --format
|
||||
// json/ndjson here.
|
||||
func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdutil.FormatOptions, svc ContinueStreamService) error {
|
||||
func runResume(ctx context.Context, opts *ResumeOptions, _ *cmdutil.FormatOptions, svc ResumeService) error {
|
||||
if opts.SessionID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "session-id argument cannot be empty")
|
||||
}
|
||||
@@ -145,7 +144,7 @@ func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdu
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--message cannot be empty")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session continue-stream: no SDK client available")
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session resume: no SDK client available")
|
||||
}
|
||||
|
||||
w := iostreams.IO.Out
|
||||
@@ -165,52 +164,27 @@ func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdu
|
||||
// 2. Open the SDK replay stream and pass each event through as a bare
|
||||
// NDJSON line. The SDK's StreamResponse is the source of truth for
|
||||
// the event vocabulary; the CLI does not reshape it.
|
||||
var streamErrMsg string
|
||||
// The SDK invokes the callback for each event (including a terminal
|
||||
// response_type=error frame) BEFORE returning, so raw passthrough still
|
||||
// emits every event; on a terminal error frame the SDK then returns an
|
||||
// *SSEStreamError. No CLI-level early-terminate is needed.
|
||||
cb := func(r *sdk.StreamResponse) error {
|
||||
isErr := r != nil && r.ResponseType == sdk.ResponseTypeError
|
||||
if isErr && streamErrMsg == "" {
|
||||
if r.Content != "" {
|
||||
streamErrMsg = r.Content
|
||||
} else if r.Data != nil {
|
||||
if e, ok := r.Data["error"].(string); ok {
|
||||
streamErrMsg = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := output.EmitSDKEvent(w, r); err != nil {
|
||||
return err
|
||||
}
|
||||
// Stop once the error frame is emitted rather than blocking on the SDK
|
||||
// read until the ~30s transport timeout (the server holds the stream
|
||||
// open after the error). streamErrMsg carries the real reason.
|
||||
if isErr {
|
||||
return sse.ErrTerminate
|
||||
}
|
||||
return nil
|
||||
return output.EmitSDKEvent(w, r)
|
||||
}
|
||||
err := svc.ContinueStream(ctx, opts.SessionID, opts.MessageID, cb)
|
||||
if errors.Is(err, sse.ErrTerminate) {
|
||||
err = nil
|
||||
}
|
||||
// A server-delivered error frame is the authoritative failure reason —
|
||||
// surface it as operation.failed (exit 1) instead of the transport timeout
|
||||
// the server's stream-close triggers afterwards. Mirrors chat / session ask.
|
||||
if streamErrMsg != "" {
|
||||
return cmdutil.NewError(cmdutil.CodeOperationFailed, "continue stream failed: "+streamErrMsg)
|
||||
}
|
||||
if err != nil {
|
||||
// Ctrl-C / SIGTERM lineage (operator gave up on the resume).
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session continue-stream cancelled")
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session resume cancelled")
|
||||
}
|
||||
// Pre-stream HTTP / transport failure (e.g. 404 if message_id is
|
||||
// unknown, or buffer-expired body from the server). Route through
|
||||
// the canonical classifier so codes survive — 404 still surfaces
|
||||
// as resource.not_found etc.
|
||||
return cmdutil.WrapHTTP(err, "continue stream")
|
||||
// WrapStream routes through ClassifySDKError: a terminal SSE error
|
||||
// frame classifies as server.error (matching chat / session ask); a
|
||||
// pre-stream HTTP failure (e.g. 404 for an unknown message_id) still
|
||||
// surfaces via ClassifyHTTPError as resource.not_found etc.
|
||||
return cmdutil.WrapStream(err, "resume stream")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: production SDK client satisfies ContinueStreamService.
|
||||
var _ ContinueStreamService = (*sdk.Client)(nil)
|
||||
// compile-time check: production SDK client satisfies ResumeService.
|
||||
var _ ResumeService = (*sdk.Client)(nil)
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// scriptedContinueStreamSvc serves a canned stream of StreamResponse events
|
||||
// to runContinueStream and records the (sessionID, messageID) passed in.
|
||||
type scriptedContinueStreamSvc struct {
|
||||
// scriptedResumeSvc serves a canned stream of StreamResponse events
|
||||
// to runResume and records the (sessionID, messageID) passed in.
|
||||
type scriptedResumeSvc struct {
|
||||
events []*sdk.StreamResponse
|
||||
streamErr error
|
||||
got struct {
|
||||
@@ -27,7 +27,7 @@ type scriptedContinueStreamSvc struct {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedContinueStreamSvc) ContinueStream(_ context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error {
|
||||
func (s *scriptedResumeSvc) ContinueStream(_ context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error {
|
||||
s.got.sessionID = sessionID
|
||||
s.got.messageID = messageID
|
||||
for _, e := range s.events {
|
||||
@@ -51,11 +51,11 @@ func contStreamComplete() *sdk.StreamResponse {
|
||||
// arrives.
|
||||
func TestContinueStream_NDJSON_FirstLineIsInitWithMessageID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{contStreamAnswer("hello"), contStreamComplete()},
|
||||
}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_xyz", MessageID: "msg_abc"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
opts := &ResumeOptions{SessionID: "sess_xyz", MessageID: "msg_abc"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
require.GreaterOrEqual(t, len(lines), 1, "expected at least the init line")
|
||||
@@ -75,15 +75,15 @@ func TestContinueStream_NDJSON_FirstLineIsInitWithMessageID(t *testing.T) {
|
||||
// events = N+1 total lines, all valid JSON.
|
||||
func TestContinueStream_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{
|
||||
contStreamAnswer("alpha"),
|
||||
contStreamAnswer("beta"),
|
||||
contStreamComplete(),
|
||||
},
|
||||
}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_y"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_y"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 3 SDK events = 4 lines.
|
||||
@@ -98,9 +98,9 @@ func TestContinueStream_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
// flow through to the SDK call.
|
||||
func TestContinueStream_PassesSessionAndMessageIDToSDK(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{events: []*sdk.StreamResponse{contStreamComplete()}}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_42", MessageID: "msg_99"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
svc := &scriptedResumeSvc{events: []*sdk.StreamResponse{contStreamComplete()}}
|
||||
opts := &ResumeOptions{SessionID: "sess_42", MessageID: "msg_99"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
assert.Equal(t, "sess_42", svc.got.sessionID)
|
||||
assert.Equal(t, "msg_99", svc.got.messageID)
|
||||
}
|
||||
@@ -110,9 +110,9 @@ func TestContinueStream_PassesSessionAndMessageIDToSDK(t *testing.T) {
|
||||
// core must also refuse empty strings).
|
||||
func TestContinueStream_EmptySessionID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{}
|
||||
opts := &ContinueStreamOptions{SessionID: "", MessageID: "msg_x"}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{}
|
||||
opts := &ResumeOptions{SessionID: "", MessageID: "msg_x"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -123,9 +123,9 @@ func TestContinueStream_EmptySessionID_Rejected(t *testing.T) {
|
||||
// point.
|
||||
func TestContinueStream_EmptyMessageID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: ""}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: ""}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -138,9 +138,9 @@ func TestContinueStream_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
svc := &scriptedContinueStreamSvc{streamErr: context.Canceled}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runContinueStream(ctx, opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{streamErr: context.Canceled}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runResume(ctx, opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -152,20 +152,36 @@ func TestContinueStream_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
// the canonical HTTP classifier.
|
||||
func TestContinueStream_NotFound_MapsToResourceNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{streamErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_missing"}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{streamErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_missing"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestResume_TerminalStreamError_MapsToServerError pins that a terminal SSE
|
||||
// error frame (surfaced by the SDK as *SSEStreamError) classifies as
|
||||
// server.error (exit 7) — the SAME as chat / session ask. Guards against the
|
||||
// prior inconsistency where resume reported the identical server condition as
|
||||
// exit 1 while chat/ask reported exit 7.
|
||||
func TestResume_TerminalStreamError_MapsToServerError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedResumeSvc{streamErr: sdk.NewSSEStreamError("no chat model configured")}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
// TestContinueStream_RequiresMessageFlag verifies cobra refuses to run the
|
||||
// command without --message (the flag is marked required).
|
||||
func TestContinueStream_RequiresMessageFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdContinueStream(f)
|
||||
cmd := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -185,7 +201,7 @@ func TestContinueStream_RequiresMessageFlag(t *testing.T) {
|
||||
// command without the positional <session-id>.
|
||||
func TestContinueStream_RequiresSessionIDArg(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdContinueStream(f)
|
||||
cmd := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package sessioncmd holds `weknora session` command tree (list / view /
|
||||
// delete / ask / continue-stream / stop) for chat history and agent invocation.
|
||||
// delete / ask / resume / stop) for chat history and agent invocation.
|
||||
//
|
||||
// Package name `sessioncmd` (not `session`) so callers can `import sdk
|
||||
// "github.com/Tencent/WeKnora/client"` and use `sdk.Session` without
|
||||
@@ -22,7 +22,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdAsk(f))
|
||||
cmd.AddCommand(NewCmdContinueStream(f))
|
||||
cmd.AddCommand(NewCmdResume(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdToolApproval(f))
|
||||
return cmd
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Unlike Ctrl-C (which only drops the local connection while the server keeps
|
||||
// generating and billing tokens), this tells the server to stop.
|
||||
//
|
||||
// This is the symmetric counterpart to `session continue-stream`: both key on
|
||||
// This is the symmetric counterpart to `session resume`: both key on
|
||||
// (session_id, message_id). The message_id comes from the init event of the
|
||||
// original chat / session ask / continue-stream stream.
|
||||
// original chat / session ask / resume stream.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
@@ -52,7 +52,7 @@ func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
session. Unlike Ctrl-C (which only drops the local connection while the server
|
||||
keeps generating and billing tokens), this tells the server to stop.
|
||||
|
||||
Symmetric with 'session continue-stream': both key on (session_id, message_id).`,
|
||||
Symmetric with 'session resume': both key on (session_id, message_id).`,
|
||||
Example: ` weknora session stop sess_xyz --message msg_abc`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
@@ -81,7 +81,7 @@ Symmetric with 'session continue-stream': both key on (session_id, message_id).`
|
||||
cmdutil.AddFormatFlag(cmd, stopFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Stop server-side generation for an in-flight assistant message (counterpart to continue-stream). The message_id comes from the init event of the chat / session ask / continue-stream stream you're stopping.",
|
||||
UsedFor: "Stop server-side generation for an in-flight assistant message (counterpart to resume). The message_id comes from the init event of the chat / session ask / resume stream you're stopping.",
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from the init event of the stream you're stopping)"},
|
||||
Examples: []string{"weknora session stop sess_xyz --message msg_abc"},
|
||||
Output: "envelope {session_id, message_id, stopped:true}",
|
||||
|
||||
@@ -49,7 +49,7 @@ When a server-side agent run (weknora session ask) needs to call a tool
|
||||
that requires approval, the stream emits a tool-approval event carrying a
|
||||
pending id and the run blocks. This command unblocks it: approve (default)
|
||||
lets the tool call execute, --reject cancels it. After resolving, resume
|
||||
the answer with weknora session continue-stream.
|
||||
the answer with weknora session resume.
|
||||
|
||||
--modified-args replaces the tool call arguments on approve (JSON object).
|
||||
It conflicts with --reject (rejected calls never execute).
|
||||
@@ -122,7 +122,7 @@ func newCmdResolve(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "session.tool_approval.resolve")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "approve or reject a pending tool call from an agent run; then resume with session continue-stream",
|
||||
UsedFor: "approve or reject a pending tool call from an agent run; then resume with session resume",
|
||||
RequiredFlags: []string{"<pending-id> (positional)"},
|
||||
Examples: []string{
|
||||
"weknora session tool-approval resolve pend_abc -y",
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
@@ -148,10 +149,13 @@ Existing files are left untouched unless --force is passed.`,
|
||||
return cmd
|
||||
}
|
||||
|
||||
// resolveDir returns the explicit --dir or the default ~/.claude/skills.
|
||||
// resolveDir returns the explicit --dir or the default ~/.claude/skills. A
|
||||
// leading ~ in --dir is expanded to the home directory — otherwise a quoted
|
||||
// `--dir '~/foo'` (which the shell leaves literal) would create a bogus "~"
|
||||
// directory tree.
|
||||
func resolveDir(dir string) (string, error) {
|
||||
if dir != "" {
|
||||
return dir, nil
|
||||
return expandTilde(dir)
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
@@ -161,6 +165,25 @@ func resolveDir(dir string) (string, error) {
|
||||
return filepath.Join(home, ".claude", "skills"), nil
|
||||
}
|
||||
|
||||
// expandTilde resolves a leading "~" or "~/" path segment to the user's home
|
||||
// directory. Other forms (including "~user" and a ~ that isn't the first
|
||||
// segment) are returned unchanged — matching the common shell behavior a CLI
|
||||
// is expected to reproduce when it receives an unexpanded literal tilde.
|
||||
func expandTilde(path string) (string, error) {
|
||||
if path != "~" && !strings.HasPrefix(path, "~/") {
|
||||
return path, nil
|
||||
}
|
||||
home, err := os.UserHomeDir()
|
||||
if err != nil {
|
||||
return "", cmdutil.NewError(cmdutil.CodeInputInvalidArgument,
|
||||
"could not expand ~ (home directory unknown); pass an absolute --dir")
|
||||
}
|
||||
if path == "~" {
|
||||
return home, nil
|
||||
}
|
||||
return filepath.Join(home, path[len("~/"):]), nil
|
||||
}
|
||||
|
||||
// writeSkills writes every embedded skill file under target, creating parent
|
||||
// dirs. Without --force, an existing file is skipped (not overwritten). Returns
|
||||
// the paths actually written.
|
||||
|
||||
@@ -73,3 +73,26 @@ func TestResolveDir(t *testing.T) {
|
||||
assert.True(t, filepath.IsAbs(def), "default dir must be absolute")
|
||||
assert.Contains(t, def, filepath.Join(".claude", "skills"))
|
||||
}
|
||||
|
||||
// TestResolveDir_ExpandsTilde pins that a leading ~ in --dir is expanded to the
|
||||
// home directory instead of creating a literal "~" directory. Regression:
|
||||
// `skills install --dir '~/foo'` (quoted, so the shell doesn't expand it) used
|
||||
// to create a bogus ./~ tree.
|
||||
func TestResolveDir_ExpandsTilde(t *testing.T) {
|
||||
home, err := os.UserHomeDir()
|
||||
require.NoError(t, err)
|
||||
|
||||
got, err := resolveDir("~/agents/skills")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, filepath.Join(home, "agents", "skills"), got)
|
||||
assert.NotContains(t, got, "~", "~ must be expanded, not left literal")
|
||||
|
||||
bare, err := resolveDir("~")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, home, bare)
|
||||
|
||||
// A ~ that is NOT a leading path segment is left untouched (not a home ref).
|
||||
lit, err := resolveDir("/tmp/a~b")
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "/tmp/a~b", lit)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user