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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
+117
View File
@@ -0,0 +1,117 @@
package messagecmd
import (
"context"
"fmt"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/prompt"
)
var messageDeleteFields = []string{"id", "deleted"}
// DeleteOptions holds the parsed flag/arg values for `message delete`.
type DeleteOptions struct {
SessionID string
MessageID string
Yes bool
DryRun bool
}
// DeleteService is the narrow SDK surface this command depends on.
type DeleteService interface {
DeleteMessage(ctx context.Context, sessionID, messageID string) error
}
// deleteResult is the typed payload emitted on success in JSON mode.
type deleteResult struct {
ID string `json:"id"`
Deleted bool `json:"deleted"`
}
const messageDeleteLong = `Permanently delete one message from a session.
Requires both the message id (positional) and the parent session id
(--session) because the server route encodes both:
DELETE /messages/{session_id}/{id}.
Deleting an assistant message breaks the turn chain for follow-up
references (session ask). Prefer deleting whole sessions
(weknora session delete) unless you specifically need to redact one turn.
Typed exit codes:
resource.not_found no message with the given id under that session (exit 4)
auth.forbidden caller lacks delete permission on the message (exit 3)
input.confirmation_required destructive op without -y on a TTY (exit 10)
AI agents: this is a high-risk write. Without -y/--yes the CLI exits 10
and writes input.confirmation_required to stderr. NEVER auto-pass -y
without the user's explicit go-ahead — the exit-10 protocol exists
exactly to guard against unintended deletes.`
// NewCmdDelete builds `weknora message delete <message-id> --session <session-id>`.
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
opts := &DeleteOptions{}
cmd := &cobra.Command{
Use: "delete <message-id> --session <session-id>",
Short: "Delete one message from a session (high-risk write)",
Long: messageDeleteLong,
Example: ` weknora message delete msg_abc --session sess_xyz # interactive confirm
weknora message delete msg_abc --session sess_xyz -y # no prompt
weknora message delete msg_abc --session sess_xyz -y --format json # agent / JSON form`,
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())
opts.MessageID = args[0]
opts.Yes, _ = c.Flags().GetBool("yes")
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
Action: "message.delete",
Args: map[string]any{"message_id": opts.MessageID, "session": opts.SessionID},
}); handled {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runDelete(c.Context(), opts, fopts, cli, f.Prompter())
},
}
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Parent session id the message lives in")
_ = cmd.MarkFlagRequired("session")
cmdutil.AddFormatFlag(cmd, messageDeleteFields...)
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
cmdutil.SetRisk(cmd, "message.delete")
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "permanently delete one message from a session",
RequiredFlags: []string{"<message-id> (positional)", "--session <session-id>"},
Examples: []string{"weknora message delete msg_abc --session sess_xyz -y"},
Output: "envelope.data is {id, deleted:true}",
Warnings: []string{
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
"message delete is irreversible; breaks the turn chain for follow-up references to that conversation turn.",
},
})
return cmd
}
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter) error {
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "message", opts.MessageID, "message.delete", []string{"weknora", "message", "delete", opts.MessageID, "--session", opts.SessionID, "-y"}); err != nil {
return err
}
if err := svc.DeleteMessage(ctx, opts.SessionID, opts.MessageID); err != nil {
return cmdutil.WrapHTTP(err, "delete message %s", opts.MessageID)
}
if fopts.WantsJSON() {
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: opts.MessageID, Deleted: true}, nil)
}
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted message %s\n", opts.MessageID)
return nil
}
+99
View File
@@ -0,0 +1,99 @@
package messagecmd
import (
"context"
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/testutil"
)
type fakeDeleteSvc struct {
err error
gotSessID string
gotMsgID string
}
func (s *fakeDeleteSvc) DeleteMessage(_ context.Context, sessionID, messageID string) error {
s.gotSessID, s.gotMsgID = sessionID, messageID
return s.err
}
// TestRunDelete_YesSkipsPromptAndDeletes verifies that -y bypasses the confirm
// prompt, calls the service with the correct IDs, and emits {"deleted":true}
// JSON.
func TestRunDelete_YesSkipsPromptAndDeletes(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{SessionID: "s1", MessageID: "m1", Yes: true}
require.NoError(t, runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}))
assert.Equal(t, "s1", svc.gotSessID)
assert.Equal(t, "m1", svc.gotMsgID)
assert.Contains(t, out.String(), `"deleted":true`)
}
// TestRunDelete_NoYesInJSONModeIsConfirmationRequired verifies that without -y
// on a non-TTY (JSON mode), the CLI returns exit 10 /
// input.confirmation_required and does NOT call the service.
func TestRunDelete_NoYesInJSONModeIsConfirmationRequired(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{}
opts := &DeleteOptions{SessionID: "s1", MessageID: "m1", Yes: false}
err := runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeInputConfirmationRequired, typed.Code)
assert.Equal(t, 10, cmdutil.ExitCode(err), "exit 10 per destructive-write protocol")
assert.Empty(t, svc.gotMsgID, "SDK must not be called without confirmation")
}
// TestRunDelete_TTY_ConfirmYes_Deletes verifies the interactive TTY path where
// the user answers yes: service is called, text output contains success line.
func TestRunDelete_TTY_ConfirmYes_Deletes(t *testing.T) {
out, _ := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: true}
opts := &DeleteOptions{SessionID: "s1", MessageID: "m1"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
require.NoError(t, runDelete(context.Background(), opts, fopts, svc, p))
assert.True(t, p.Asked)
assert.Equal(t, "m1", svc.gotMsgID)
assert.Equal(t, "s1", svc.gotSessID)
assert.Contains(t, out.String(), "Deleted message m1")
}
// TestRunDelete_TTY_ConfirmNo_Aborts verifies the interactive TTY path where
// the user declines: service is NOT called, error has CodeUserAborted.
func TestRunDelete_TTY_ConfirmNo_Aborts(t *testing.T) {
_, errBuf := iostreams.SetForTestWithTTY(t)
svc := &fakeDeleteSvc{}
p := &testutil.ConfirmPrompter{Answer: false}
opts := &DeleteOptions{SessionID: "s1", MessageID: "m1"}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
err := runDelete(context.Background(), opts, fopts, svc, p)
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
assert.Empty(t, svc.gotMsgID, "answer=no must not call DeleteMessage")
assert.Contains(t, errBuf.String(), "Aborted")
}
// TestRunDelete_ServiceError_Propagates verifies that an SDK-level error is
// wrapped and returned (service called but returns HTTP error).
func TestRunDelete_ServiceError_Propagates(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
opts := &DeleteOptions{SessionID: "s1", MessageID: "missing", Yes: true}
err := runDelete(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})
require.Error(t, err)
var typed *cmdutil.Error
require.ErrorAs(t, err, &typed)
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
}
+130
View File
@@ -0,0 +1,130 @@
package messagecmd
import (
"context"
"fmt"
"text/tabwriter"
"time"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// messageListFields enumerates the projectable scalar fields of sdk.Message
// (nested knowledge_references / agent_steps are intentionally excluded).
var messageListFields = []string{
"id", "session_id", "request_id", "role", "content",
"is_completed", "channel", "created_at", "updated_at",
}
type ListOptions struct {
SessionID string
Limit int
// Before filters to messages created before this RFC3339 timestamp.
// The server pages by time cursor (limit + before_time), not by page
// number — to walk further back, re-run with --before set to the
// oldest created_at from the previous batch.
Before string
}
// ListService is the narrow SDK surface this command depends on.
type ListService interface {
LoadMessages(ctx context.Context, sessionID string, limit int, beforeTime *time.Time, opts ...sdk.ResourceURLOptions) ([]sdk.Message, error)
}
// NewCmdList builds `weknora message list --session <id>`.
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
opts := &ListOptions{}
cmd := &cobra.Command{
Use: "list --session <session-id>",
Short: "List messages in a chat session (newest first, time-cursor paged)",
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
fopts, err := cmdutil.CheckFormatFlag(c)
if err != nil {
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
}
return runList(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Session id to load messages from")
_ = cmd.MarkFlagRequired("session")
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 30, "Maximum messages to return — server-side page size (1..1000)")
cmd.Flags().StringVar(&opts.Before, "before", "", "Only messages created before this RFC3339 timestamp (time-cursor pagination)")
cmdutil.AddFormatFlag(cmd, messageListFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "List messages in a session. Use the assistant message id from the latest turn to chain follow-ups (session ask), or --before <oldest created_at> to page further back.",
RequiredFlags: []string{"--session <session-id>"},
Examples: []string{
"weknora message list --session sess_abc",
"weknora message list --session sess_abc --limit 100 --before 2026-06-01T00:00:00Z",
},
Output: "envelope.data is an array of Message objects (id, session_id, role, content, is_completed, created_at); meta.count is the number returned. To page further back, re-run with --before <oldest created_at>; a batch shorter than --limit means the start of history was reached",
})
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 > 1000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..1000, 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
}
before, err := cmdutil.ParseTimeFlag("--before", opts.Before)
if err != nil {
return err
}
items, err := svc.LoadMessages(ctx, opts.SessionID, opts.Limit, before)
if err != nil {
return cmdutil.WrapHTTP(err, "list messages for session %s", opts.SessionID)
}
if items == nil {
items = []sdk.Message{} // JSON [] not null
}
if fopts.WantsJSON() {
// No has_more here: the server exposes neither a total nor a cursor
// for message loads, and fabricating one from len==limit would give
// the same envelope key a second, heuristic meaning (elsewhere
// has_more strictly means "client-side truncation happened").
// Consumers walk back with --before until a batch comes up short.
meta := &output.Meta{Count: output.IntPtr(len(items))}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no messages)")
return nil
}
now := time.Now()
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "ID\tROLE\tCONTENT\tCREATED")
for _, m := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%s\n", m.ID, m.Role, text.OneLine(60, m.Content), text.FuzzyAgo(now, m.CreatedAt))
}
return tw.Flush()
}
+119
View File
@@ -0,0 +1,119 @@
package messagecmd
import (
"context"
"errors"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
// jsonOpts is the package-local FormatOptions shorthand (same pattern as
// chunk delete_test.go's inline construction).
func jsonOpts() *cmdutil.FormatOptions { return &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON} }
type fakeListSvc struct {
items []sdk.Message
err error
gotSessID string
gotLimit int
gotBefore *time.Time
}
func (s *fakeListSvc) LoadMessages(_ context.Context, sessionID string, limit int, before *time.Time, opts ...sdk.ResourceURLOptions) ([]sdk.Message, error) {
s.gotSessID, s.gotLimit, s.gotBefore = sessionID, limit, before
return s.items, s.err
}
func TestRunList_PassesArgsAndEmitsJSON(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeListSvc{items: []sdk.Message{{ID: "m1", SessionID: "s1", Role: "assistant", Content: "hi"}}}
opts := &ListOptions{SessionID: "s1", Limit: 20}
require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc))
assert.Equal(t, "s1", svc.gotSessID)
assert.Equal(t, 20, svc.gotLimit)
assert.Nil(t, svc.gotBefore)
assert.Contains(t, out.String(), `"id":"m1"`)
assert.Contains(t, out.String(), `"count":1`)
// message list never emits has_more: the server provides no total or
// cursor, so the CLI does not fabricate one (omitempty keeps it absent)
assert.NotContains(t, out.String(), `"has_more"`)
}
func TestRunList_BeforeParsedAsRFC3339(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{}
opts := &ListOptions{SessionID: "s1", Limit: 20, Before: "2026-06-01T00:00:00Z"}
require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc))
require.NotNil(t, svc.gotBefore)
assert.Equal(t, 2026, svc.gotBefore.Year())
}
func TestRunList_BadBeforeIsInvalidArgument(t *testing.T) {
_, _ = iostreams.SetForTest(t)
err := runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20, Before: "yesterday"}, jsonOpts(), &fakeListSvc{})
require.Error(t, err)
assert.Contains(t, err.Error(), "RFC3339")
var cliErr *cmdutil.Error
require.True(t, errors.As(err, &cliErr))
assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code)
}
func TestRunList_EmptyIsJSONArrayNotNull(t *testing.T) {
out, _ := iostreams.SetForTest(t)
require.NoError(t, runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20}, jsonOpts(), &fakeListSvc{}))
assert.Contains(t, out.String(), `"data":[]`)
}
// TestRunList_TextMode_NewlineInContent asserts that a message whose Content
// contains embedded newlines is collapsed to a single tabwriter row (OneLine).
func TestRunList_TextMode_NewlineInContent(t *testing.T) {
out, _ := iostreams.SetForTest(t)
ts := time.Now().Add(-5 * time.Minute)
svc := &fakeListSvc{items: []sdk.Message{
{ID: "m1", Role: "user", Content: "first line\nsecond line\nthird line", CreatedAt: ts},
}}
opts := &ListOptions{SessionID: "s1", Limit: 20}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
require.NoError(t, runList(context.Background(), opts, fopts, svc))
got := out.String()
// Header + 1 data row = exactly 2 lines. TrimRight removes the trailing
// newline that tabwriter emits, so Split should produce exactly 2 tokens.
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
assert.Len(t, lines, 2, "newlines in content must be collapsed to a single row: got %q", got)
// The data row must contain "m1" (id) and not a bare literal \n inside it.
assert.Contains(t, lines[1], "m1")
}
// TestRunList_ServiceError_ReturnsError asserts that a service-level error
// propagates out of runList.
func TestRunList_ServiceError_ReturnsError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeListSvc{err: errors.New("HTTP error 503: service unavailable")}
err := runList(context.Background(), &ListOptions{SessionID: "s1", Limit: 20}, jsonOpts(), svc)
require.Error(t, err)
}
// TestRunList_NoHasMoreEvenWhenLimitFilled pins the contract that message
// list never fabricates has_more — even a limit-filled batch (the case a
// heuristic would flag) must not emit the key, because the server provides
// no total/cursor and elsewhere has_more means client-side truncation.
func TestRunList_NoHasMoreEvenWhenLimitFilled(t *testing.T) {
out, _ := iostreams.SetForTest(t)
items := []sdk.Message{
{ID: "m1", Role: "user", Content: "a"},
{ID: "m2", Role: "assistant", Content: "b"},
{ID: "m3", Role: "user", Content: "c"},
}
opts := &ListOptions{SessionID: "s1", Limit: 3}
svc := &fakeListSvc{items: items}
require.NoError(t, runList(context.Background(), opts, jsonOpts(), svc))
assert.NotContains(t, out.String(), `"has_more"`)
}
+21
View File
@@ -0,0 +1,21 @@
// Package messagecmd implements `weknora message` — inspect and manage the
// messages inside chat sessions (the multi-turn substrate behind session ask).
package messagecmd
import (
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
)
// NewCmd builds the `weknora message` command group.
func NewCmd(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "message",
Short: "Inspect and manage messages inside chat sessions",
}
cmd.AddCommand(NewCmdList(f))
cmd.AddCommand(NewCmdSearch(f))
cmd.AddCommand(NewCmdDelete(f))
return cmd
}
+143
View File
@@ -0,0 +1,143 @@
package messagecmd
import (
"context"
"fmt"
"text/tabwriter"
"github.com/spf13/cobra"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
"github.com/Tencent/WeKnora/cli/internal/output"
"github.com/Tencent/WeKnora/cli/internal/text"
sdk "github.com/Tencent/WeKnora/client"
)
// messageSearchFields enumerates the projectable fields of MessageSearchGroupItem.
var messageSearchFields = []string{
"request_id", "session_id", "session_title", "query_content",
"answer_content", "score", "match_type", "created_at",
}
// messageSearchModes is the closed set the server accepts for --mode, sourced
// from the SDK enumerator so it can't drift from the server vocabulary.
var messageSearchModes = cmdutil.EnumStrings(sdk.AllMessageSearchModes())
// SearchOptions holds the parsed flag values for `message search`.
type SearchOptions struct {
Query string
Mode string // server-defined search mode, passed through verbatim
Limit int
SessionIDs []string
}
// SearchService is the narrow SDK surface this command depends on.
type SearchService interface {
SearchMessages(ctx context.Context, req *sdk.SearchMessagesRequest) (*sdk.MessageSearchResult, error)
}
// NewCmdSearch builds `weknora message search "<query>"`.
func NewCmdSearch(f *cmdutil.Factory) *cobra.Command {
opts := &SearchOptions{}
cmd := &cobra.Command{
Use: `search "<query>"`,
Short: "Search chat history across sessions (question/answer pairs)",
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())
opts.Query = args[0]
// Validate static input (--limit / --mode) before building the
// client so a bad value returns input.invalid_argument (exit 5)
// instead of an auth error (exit 3) when the profile is
// unconfigured — an agent must see the real, fixable problem.
if err := validateSearchOpts(opts); err != nil {
return err
}
cli, err := f.Client()
if err != nil {
return err
}
return runSearch(c.Context(), opts, fopts, cli)
},
}
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", 20, "Maximum results to return — server-side limit (1..1000; default 20 mirrors the server default)")
cmd.Flags().StringVar(&opts.Mode, "mode", "", "Search mode: keyword | vector | hybrid (omit for server default: hybrid)")
cmd.Flags().StringArrayVar(&opts.SessionIDs, "session", nil, "Restrict search to these session ids (repeatable)")
cmdutil.AddFormatFlag(cmd, messageSearchFields...)
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
UsedFor: "Search past Q&A pairs across chat history. This searches conversation messages — for RAG retrieval over documents use `weknora search chunks` instead.",
RequiredFlags: []string{"<query> (positional)"},
Examples: []string{
`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.count is the returned count, meta.total_count is the server-side total. --mode accepts keyword | vector | hybrid (server default: hybrid)",
})
return cmd
}
// validateSearchOpts checks --limit / --mode and normalizes --mode to the
// canonical form. Called from RunE before the client is built (so bad input
// surfaces as exit 5, not an auth error) and again at the top of runSearch
// for direct callers; it is idempotent.
func validateSearchOpts(opts *SearchOptions) error {
// 1..1000 mirrors `search chunks` (the sibling search command); the
// server itself does not cap limit (handler passes it through).
if opts.Limit < 1 || opts.Limit > 1000 {
return &cmdutil.Error{
Code: cmdutil.CodeInputInvalidArgument,
Message: fmt.Sprintf("--limit must be in 1..1000, got %d", opts.Limit),
}
}
// Validate --mode against the closed server enum and normalize to the
// canonical (lowercase) form the server matches. An unrecognised mode
// matches no channel server-side and returns an empty set with no error —
// which an agent cannot tell apart from a genuine no-match — so reject it.
mode, err := cmdutil.ValidateEnum("mode", opts.Mode, messageSearchModes)
if err != nil {
return err
}
opts.Mode = mode
return nil
}
func runSearch(ctx context.Context, opts *SearchOptions, fopts *cmdutil.FormatOptions, svc SearchService) error {
if err := validateSearchOpts(opts); err != nil {
return err
}
res, err := svc.SearchMessages(ctx, &sdk.SearchMessagesRequest{
Query: opts.Query,
Mode: opts.Mode,
Limit: opts.Limit,
SessionIDs: opts.SessionIDs,
})
if err != nil {
return cmdutil.WrapHTTP(err, "search messages")
}
if res == nil {
res = &sdk.MessageSearchResult{}
}
items := res.Items
if items == nil {
items = []*sdk.MessageSearchGroupItem{}
}
if fopts.WantsJSON() {
meta := &output.Meta{Count: output.IntPtr(len(items)), TotalCount: output.IntPtr(res.Total)}
return fopts.Emit(iostreams.IO.Out, items, meta)
}
if len(items) == 0 {
fmt.Fprintln(iostreams.IO.Out, "(no matches)")
return nil
}
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
fmt.Fprintln(tw, "SESSION ID\tQUERY\tANSWER\tSCORE")
for _, it := range items {
fmt.Fprintf(tw, "%s\t%s\t%s\t%.2f\n", it.SessionID, text.OneLine(40, it.QueryContent), text.OneLine(40, it.AnswerContent), it.Score)
}
return tw.Flush()
}
+153
View File
@@ -0,0 +1,153 @@
package messagecmd
import (
"context"
"errors"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
"github.com/Tencent/WeKnora/cli/internal/iostreams"
sdk "github.com/Tencent/WeKnora/client"
)
type fakeSearchSvc struct {
result *sdk.MessageSearchResult
err error
gotReq *sdk.SearchMessagesRequest
}
func (s *fakeSearchSvc) SearchMessages(_ context.Context, req *sdk.SearchMessagesRequest) (*sdk.MessageSearchResult, error) {
s.gotReq = req
if s.err != nil {
return nil, s.err
}
return s.result, nil
}
// --- baseline tests (from spec) ---
func TestRunSearch_EmitsItemsAndTotal(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{
Items: []*sdk.MessageSearchGroupItem{{RequestID: "r1", SessionID: "s1", QueryContent: "q", AnswerContent: "a"}},
Total: 7,
}}
opts := &SearchOptions{Query: "deploy steps", Limit: 20}
require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc))
assert.Equal(t, "deploy steps", svc.gotReq.Query)
assert.Contains(t, out.String(), `"request_id":"r1"`)
assert.Contains(t, out.String(), `"total_count":7`)
}
func TestRunSearch_SessionScopePassedThrough(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}}
opts := &SearchOptions{Query: "q", Limit: 20, SessionIDs: []string{"s1", "s2"}}
require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc))
assert.Equal(t, []string{"s1", "s2"}, svc.gotReq.SessionIDs)
}
// --- extended quality tests ---
// TestRunSearch_LimitOutOfRange_InvalidArgument asserts that a limit outside
// 1..1000 is rejected with a typed *cmdutil.Error and CodeInputInvalidArgument.
func TestRunSearch_LimitOutOfRange_InvalidArgument(t *testing.T) {
_, _ = iostreams.SetForTest(t)
cases := []struct {
name string
limit int
}{
{"zero", 0},
{"negative", -5},
{"above max", 1001},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: tc.limit}, jsonOpts(), &fakeSearchSvc{})
require.Error(t, err)
var cliErr *cmdutil.Error
require.True(t, errors.As(err, &cliErr), "expected *cmdutil.Error, got %T", err)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code)
})
}
}
// TestRunSearch_InvalidMode_InvalidArgument: a --mode outside the closed enum
// is rejected with input.invalid_argument rather than silently returning empty.
func TestRunSearch_InvalidMode_InvalidArgument(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}}
err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20, Mode: "hybird"}, jsonOpts(), svc)
require.Error(t, err)
var cliErr *cmdutil.Error
require.True(t, errors.As(err, &cliErr), "expected *cmdutil.Error, got %T", err)
assert.Equal(t, cmdutil.CodeInputInvalidArgument, cliErr.Code)
assert.Nil(t, svc.gotReq, "must reject before calling the server")
}
// TestRunSearch_ModeNormalized: a valid mode in any case is normalized to the
// lowercase form the server matches before being sent.
func TestRunSearch_ModeNormalized(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}}
require.NoError(t, runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20, Mode: "Hybrid"}, jsonOpts(), svc))
assert.Equal(t, "hybrid", svc.gotReq.Mode)
}
// TestRunSearch_TextMode_NewlineInContent asserts that embedded newlines in
// query/answer content are collapsed to a single tabwriter row (OneLine).
func TestRunSearch_TextMode_NewlineInContent(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{
Items: []*sdk.MessageSearchGroupItem{{
SessionID: "sess1",
QueryContent: "line one\nline two\nline three",
AnswerContent: "answer line\nanother line",
Score: 0.9,
}},
Total: 1,
}}
opts := &SearchOptions{Query: "q", Limit: 20}
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
require.NoError(t, runSearch(context.Background(), opts, fopts, svc))
got := out.String()
// Header + 1 data row = exactly 2 lines.
lines := strings.Split(strings.TrimRight(got, "\n"), "\n")
assert.Len(t, lines, 2, "newlines in content must be collapsed to a single row: got %q", got)
assert.Contains(t, lines[1], "sess1")
// Score must render with the %.2f format: 0.9 → "0.90" (pin the formatting).
assert.Contains(t, lines[1], "0.90", "score must render with %%.2f formatting: got %q", got)
}
// TestRunSearch_ServiceError_ReturnsError asserts that a service-level error
// propagates out of runSearch.
func TestRunSearch_ServiceError_ReturnsError(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSearchSvc{err: errors.New("HTTP error 503: service unavailable")}
err := runSearch(context.Background(), &SearchOptions{Query: "q", Limit: 20}, jsonOpts(), svc)
require.Error(t, err)
}
// TestRunSearch_EmptyResult_JSONArrayNotNull asserts that an empty result set
// is serialised as "data":[] and not "data":null.
func TestRunSearch_EmptyResult_JSONArrayNotNull(t *testing.T) {
out, _ := iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{Items: nil, Total: 0}}
opts := &SearchOptions{Query: "q", Limit: 20}
require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc))
assert.Contains(t, out.String(), `"data":[]`)
}
// TestRunSearch_ModePassedThrough asserts that a valid --mode value is
// forwarded to the SDK request.
func TestRunSearch_ModePassedThrough(t *testing.T) {
_, _ = iostreams.SetForTest(t)
svc := &fakeSearchSvc{result: &sdk.MessageSearchResult{}}
opts := &SearchOptions{Query: "q", Limit: 20, Mode: "vector"}
require.NoError(t, runSearch(context.Background(), opts, jsonOpts(), svc))
assert.Equal(t, "vector", svc.gotReq.Mode)
}