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
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:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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"`)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user