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,302 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// sessionAskFields enumerates the fields surfaced for `--jq` projection
|
||||
// discovery on `session ask`: the json object's data fields + the raw SDK
|
||||
// agent event vocabulary used by --format ndjson.
|
||||
var sessionAskFields = []string{
|
||||
"events", "session_id", "agent_id", "query",
|
||||
// NDJSON init + SDK event fields.
|
||||
"type", "profile", "response_type", "content", "done", "knowledge_references", "data",
|
||||
}
|
||||
|
||||
// AskOptions captures `session ask` flag state.
|
||||
type AskOptions struct {
|
||||
AgentID string
|
||||
Query string
|
||||
SessionID string // --session: continue an existing session (skip auto-create)
|
||||
// Reference adds bounded kb_id/chunk_id reference events to JSON/text.
|
||||
Reference bool
|
||||
// Verbose surfaces the projected execution trace in JSON/text,
|
||||
// including reasoning, tools, and lifecycle events.
|
||||
// NDJSON is always raw and is unaffected by presentation flags.
|
||||
Verbose bool
|
||||
}
|
||||
|
||||
// AskService is the narrow SDK surface this command depends on.
|
||||
//
|
||||
// CreateSession is called when --session is omitted — sessions are
|
||||
// agent-agnostic at creation (verified against
|
||||
// internal/handler/session/handler.go CreateSession, which only persists
|
||||
// {title, description}). The agent ID is supplied per-request via
|
||||
// AgentQARequest.AgentID, so the same session can be reused across
|
||||
// agent / KB-chat invocations.
|
||||
type AskService interface {
|
||||
CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error)
|
||||
AgentQAStreamWithRequest(ctx context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
// NewCmdAsk builds `weknora session ask --agent <agent-id> "<text>"`.
|
||||
func NewCmdAsk(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &AskOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: `ask "<text>"`,
|
||||
Short: "Ask a server-side agent in a session context",
|
||||
Long: `Invoke a server-side agent within a session. If --session is omitted,
|
||||
a new session is auto-created and its id is reported in the output for
|
||||
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 /
|
||||
update / delete / status / check).
|
||||
|
||||
Modes:
|
||||
--format json (default): one JSON envelope with answer events
|
||||
--format text: live human-readable answer stream
|
||||
--format ndjson: raw NDJSON event stream — init line (session_id,
|
||||
agent_id) then SDK agent events verbatim. Debug.
|
||||
|
||||
Pass --reference to include bounded kb_id/chunk_id reference indexes. Pass
|
||||
--verbose to include reasoning, tool activity, and lifecycle frames. Combine
|
||||
both flags for the complete projected stream.`,
|
||||
Example: ` weknora session ask --agent ag_x "Summarize Q3 sales"
|
||||
weknora session ask --session sess_x --agent ag_x "Follow-up question"
|
||||
weknora session ask --agent ag_x "Multi-step task" --format ndjson`,
|
||||
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 = strings.TrimSpace(args[0])
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runAsk(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.AgentID, "agent", "a", "", "Agent ID to invoke (required)")
|
||||
_ = cmd.MarkFlagRequired("agent")
|
||||
cmd.Flags().StringVar(&opts.SessionID, "session", "", "Continue an existing chat session (skip auto-create)")
|
||||
cmd.Flags().BoolVar(&opts.Reference, "reference", false, "Include indexed references in JSON/text output")
|
||||
cmd.Flags().BoolVar(&opts.Verbose, "verbose", false, "Include reasoning, tools, and lifecycle events in JSON/text output")
|
||||
cmdutil.AddFormatFlag(cmd, sessionAskFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Invoke a custom agent in a session context. Default JSON returns a bounded answer-event projection. --reference adds indexed citations; --verbose adds reasoning, tools, and lifecycle events. --format ndjson streams raw SDK agent events; --format text renders the selected events live.",
|
||||
RequiredFlags: []string{"<text> (positional)", "--agent"},
|
||||
Examples: []string{
|
||||
`weknora session ask --agent ag_x "Summarize Q3 sales"`,
|
||||
`weknora session ask --agent ag_x "Summarize Q3 sales" --jq '[.data.events[].content] | join("")'`,
|
||||
`weknora session ask --session sess_x --agent ag_x "Follow-up question"`,
|
||||
},
|
||||
Output: "Default --format json: {ok,data:{events:[answer...],session_id,agent_id,query}}. --reference adds kb_id/chunk_id reference events; --verbose adds execution events. --format ndjson remains raw.",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runAsk(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions, svc AskService) error {
|
||||
if opts.Query == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "query argument cannot be empty")
|
||||
}
|
||||
if opts.AgentID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "agent-id argument cannot be empty")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session ask: no SDK client available")
|
||||
}
|
||||
|
||||
// --format selects the output shape: json (default) accumulates and
|
||||
// emits one object, ndjson streams raw SDK agent events, text renders.
|
||||
sessionID := opts.SessionID
|
||||
autoCreated := false
|
||||
if sessionID == "" {
|
||||
sess, err := svc.CreateSession(ctx, &sdk.CreateSessionRequest{Title: "weknora session ask"})
|
||||
if err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session ask cancelled")
|
||||
}
|
||||
code := cmdutil.ClassifyHTTPError(err)
|
||||
if code == cmdutil.CodeNetworkError || code == cmdutil.CodeServerError {
|
||||
code = cmdutil.CodeSessionCreateFailed
|
||||
}
|
||||
return cmdutil.Wrapf(code, err, "create chat session")
|
||||
}
|
||||
sessionID = sess.ID
|
||||
autoCreated = true
|
||||
}
|
||||
|
||||
if fopts != nil && fopts.Mode == cmdutil.FormatNDJSON {
|
||||
return runAskNDJSON(ctx, opts, sessionID, svc)
|
||||
}
|
||||
if fopts != nil && fopts.Mode == cmdutil.FormatJSON {
|
||||
return runAskJSON(ctx, opts, fopts, sessionID, svc)
|
||||
}
|
||||
|
||||
// Surface auto-created session id up-front so a ^C mid-stream still
|
||||
// leaves a recoverable pointer. Skipped in json/ndjson mode (session_id
|
||||
// is in the output).
|
||||
if autoCreated {
|
||||
fmt.Fprintf(iostreams.IO.Err, "session: %s (use --session to continue)\n", sessionID)
|
||||
}
|
||||
|
||||
return runAskText(ctx, opts, sessionID, autoCreated, svc)
|
||||
}
|
||||
|
||||
// runAskNDJSON handles the --format ndjson path: emits a CLI init event at
|
||||
// stream head, then passes every SDK agent event through verbatim as NDJSON
|
||||
// lines. No buffering.
|
||||
func runAskNDJSON(ctx context.Context, opts *AskOptions, sessionID string, svc AskService) error {
|
||||
w := iostreams.IO.Out
|
||||
|
||||
// 1. Inject the CLI-managed init event at the head of the stream.
|
||||
// Carries session pointer + agent id callers need for follow-up threading.
|
||||
initEv := output.InitEvent{
|
||||
SessionID: sessionID,
|
||||
AgentID: opts.AgentID,
|
||||
Profile: cmdutil.GetProfile(),
|
||||
}
|
||||
if err := output.EmitInit(w, initEv); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. Open SDK stream and pass each agent event through as a bare NDJSON line.
|
||||
req := &sdk.AgentQARequest{
|
||||
Query: opts.Query,
|
||||
AgentEnabled: true,
|
||||
AgentID: opts.AgentID,
|
||||
Channel: "api",
|
||||
}
|
||||
cb := func(r *sdk.AgentStreamResponse) error {
|
||||
// NDJSON is the raw protocol/debug surface: do not filter events or
|
||||
// mutate their payloads. JSON/text modes own presentation filtering.
|
||||
return output.EmitSDKEvent(w, r)
|
||||
}
|
||||
if err := svc.AgentQAStreamWithRequest(ctx, sessionID, req, cb); err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session ask cancelled")
|
||||
}
|
||||
return cmdutil.WrapStream(err, "agent-chat stream")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// runAskText handles the --format text path. It renders the same
|
||||
// projection as JSON immediately for both terminals and pipes.
|
||||
func runAskText(ctx context.Context, opts *AskOptions, sessionID string, autoCreated bool, svc AskService) error {
|
||||
req := &sdk.AgentQARequest{
|
||||
Query: opts.Query,
|
||||
AgentEnabled: true,
|
||||
AgentID: opts.AgentID,
|
||||
Channel: "api",
|
||||
}
|
||||
|
||||
projector := sse.NewProjector(opts.Verbose, opts.Reference, "")
|
||||
renderer := sse.NewTextRenderer(iostreams.IO.Out, opts.Verbose)
|
||||
cb := func(r *sdk.AgentStreamResponse) error {
|
||||
event, include := projector.Agent(r)
|
||||
if !include {
|
||||
return nil
|
||||
}
|
||||
return renderer.Write(event)
|
||||
}
|
||||
|
||||
streamErr := svc.AgentQAStreamWithRequest(ctx, sessionID, req, cb)
|
||||
if streamErr != nil {
|
||||
if autoCreated {
|
||||
fmt.Fprintf(iostreams.IO.Err, "session: %s (resume with --session %s)\n", sessionID, sessionID)
|
||||
}
|
||||
if cmdutil.IsCancelled(ctx, streamErr) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, streamErr, "session ask cancelled")
|
||||
}
|
||||
if projector.Seen() && !projector.Done() {
|
||||
return cmdutil.Wrapf(cmdutil.CodeSSEStreamAborted, streamErr, "stream aborted before completion")
|
||||
}
|
||||
return cmdutil.WrapStream(streamErr, "agent-chat stream")
|
||||
}
|
||||
|
||||
// Server closed cleanly but never sent a complete event — treat as aborted
|
||||
// so agents don't silently emit a truncated answer as ok=true.
|
||||
if !projector.Done() {
|
||||
return cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event")
|
||||
}
|
||||
return renderer.Close()
|
||||
}
|
||||
|
||||
// runAskJSON handles the --format json path (the default): collect the
|
||||
// projection and emit it in one normal success envelope.
|
||||
func runAskJSON(ctx context.Context, opts *AskOptions, fopts *cmdutil.FormatOptions, sessionID string, svc AskService) error {
|
||||
req := &sdk.AgentQARequest{
|
||||
Query: opts.Query,
|
||||
AgentEnabled: true,
|
||||
AgentID: opts.AgentID,
|
||||
Channel: "api",
|
||||
}
|
||||
|
||||
projector := sse.NewProjector(opts.Verbose, opts.Reference, "")
|
||||
events := make([]sse.ProjectedEvent, 0)
|
||||
cb := func(r *sdk.AgentStreamResponse) error {
|
||||
if event, include := projector.Agent(r); include {
|
||||
events = append(events, event)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
if err := svc.AgentQAStreamWithRequest(ctx, sessionID, req, cb); err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return askStreamError(cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session ask cancelled"), sessionID)
|
||||
}
|
||||
return askStreamError(cmdutil.WrapStream(err, "agent-chat stream"), sessionID)
|
||||
}
|
||||
if !projector.Done() {
|
||||
return askStreamError(
|
||||
cmdutil.NewError(cmdutil.CodeSSEStreamAborted, "stream ended without a terminal event"),
|
||||
sessionID,
|
||||
)
|
||||
}
|
||||
|
||||
data := askResult{
|
||||
Events: events,
|
||||
SessionID: sessionID,
|
||||
AgentID: opts.AgentID,
|
||||
Query: opts.Query,
|
||||
}
|
||||
|
||||
// Reaching here means fopts.Mode is FormatJSON (the only caller). Route
|
||||
// through FormatOptions.Emit so --jq projection and the success-envelope
|
||||
// contract apply. A nil fopts (direct-test entry) defaults to JSON.
|
||||
if fopts == nil {
|
||||
fopts = &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
}
|
||||
return fopts.Emit(iostreams.IO.Out, data, nil)
|
||||
}
|
||||
|
||||
func askStreamError(err *cmdutil.Error, sessionID string) *cmdutil.Error {
|
||||
return err.WithDetail(map[string]any{"session_id": sessionID})
|
||||
}
|
||||
|
||||
// askResult is the --format json data payload. The projector controls default
|
||||
// versus verbose coverage.
|
||||
type askResult struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Query string `json:"query"`
|
||||
}
|
||||
|
||||
// compile-time check: production SDK client satisfies AskService.
|
||||
var _ AskService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,665 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// scriptedAskSvc serves a canned stream of agent events to runAsk.
|
||||
type scriptedAskSvc struct {
|
||||
createResp *sdk.Session
|
||||
createErr error
|
||||
events []*sdk.AgentStreamResponse
|
||||
streamErr error
|
||||
got struct {
|
||||
sessionID string
|
||||
req *sdk.AgentQARequest
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedAskSvc) CreateSession(_ context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) {
|
||||
if s.createResp == nil && s.createErr == nil {
|
||||
return &sdk.Session{ID: "sess_auto", Title: req.Title}, nil
|
||||
}
|
||||
return s.createResp, s.createErr
|
||||
}
|
||||
|
||||
func (s *scriptedAskSvc) AgentQAStreamWithRequest(_ context.Context, sessionID string, req *sdk.AgentQARequest, cb sdk.AgentEventCallback, opts ...sdk.ResourceURLOptions) error {
|
||||
s.got.sessionID = sessionID
|
||||
s.got.req = req
|
||||
for _, e := range s.events {
|
||||
if err := cb(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.streamErr
|
||||
}
|
||||
|
||||
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}
|
||||
}
|
||||
func toolCallEvent(id, name string) *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{
|
||||
ResponseType: sdk.AgentResponseTypeToolCall,
|
||||
ID: id,
|
||||
Content: name,
|
||||
}
|
||||
}
|
||||
func referencesEvent(refs []*sdk.SearchResult) *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{
|
||||
ResponseType: sdk.AgentResponseTypeReferences,
|
||||
KnowledgeReferences: refs,
|
||||
}
|
||||
}
|
||||
|
||||
// textOpts returns a FormatOptions configured for the text render path —
|
||||
// the most common shape under test.
|
||||
func textOpts() *cmdutil.FormatOptions {
|
||||
return &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
}
|
||||
|
||||
// ndjsonOpts returns a FormatOptions for the NDJSON event-stream path
|
||||
// (--format ndjson: raw SDK agent events, one per line).
|
||||
func ndjsonOpts() *cmdutil.FormatOptions {
|
||||
return &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
}
|
||||
|
||||
// jsonOpts returns a FormatOptions configured for the JSON object path
|
||||
// (--format json: one accumulated {ok,data} envelope).
|
||||
func jsonOpts() *cmdutil.FormatOptions {
|
||||
return &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}
|
||||
}
|
||||
|
||||
// TestSessionAsk_NDJSON_FirstLineIsInit verifies that the NDJSON path (--format ndjson)
|
||||
// always injects an "init" line first, carrying session_id and agent_id.
|
||||
func TestSessionAsk_NDJSON_FirstLineIsInit(t *testing.T) {
|
||||
out, errBuf := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
answerEvent("answer"),
|
||||
doneEvent(),
|
||||
},
|
||||
}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "ping"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
|
||||
// NDJSON mode must NOT print the session hint to stderr.
|
||||
if errBuf.Len() != 0 {
|
||||
t.Errorf("expected empty stderr in NDJSON mode, got %q", errBuf.String())
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
if len(lines) == 0 {
|
||||
t.Fatal("no output")
|
||||
}
|
||||
var first struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(lines[0]), &first); err != nil {
|
||||
t.Fatalf("first line not JSON: %v\n %s", err, lines[0])
|
||||
}
|
||||
if first.Type != "init" {
|
||||
t.Errorf("first line type: got %q, want init", first.Type)
|
||||
}
|
||||
if first.SessionID != "sess_auto" {
|
||||
t.Errorf("init.session_id: got %q, want sess_auto", first.SessionID)
|
||||
}
|
||||
if first.AgentID != "ag_x" {
|
||||
t.Errorf("init.agent_id: got %q, want ag_x", first.AgentID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_NDJSON_PassthroughEvents verifies init + N SDK events = N+1 total lines.
|
||||
func TestSessionAsk_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
answerEvent("hello"),
|
||||
doneEvent(),
|
||||
},
|
||||
}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 2 SDK events = 3 lines.
|
||||
if len(lines) != 3 {
|
||||
t.Fatalf("got %d lines, want 3:\n%s", len(lines), out.String())
|
||||
}
|
||||
// Each must be valid JSON.
|
||||
for i, line := range lines {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Errorf("line %d not valid JSON: %v\n %s", i+1, err, line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_FormatJSON_EmitsSingleEnvelope verifies that default JSON
|
||||
// keeps answer events only.
|
||||
func TestSessionAsk_FormatJSON_EmitsSingleEnvelope(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
// Per-event Done markers are not terminal for the whole agent run.
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "hidden reasoning", Done: true},
|
||||
toolCallEvent("call_1", "knowledge_search"),
|
||||
referencesEvent([]*sdk.SearchResult{{ID: "c1", Content: "BULKY PASSAGE", KnowledgeTitle: "Doc"}}),
|
||||
answerEvent("the answer"),
|
||||
{ResponseType: sdk.AgentResponseTypeAnswer, Done: true},
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi"}
|
||||
if err := runAsk(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
// A single envelope, not multiple NDJSON lines.
|
||||
outStr := strings.TrimRight(out.String(), "\n")
|
||||
if strings.Contains(outStr, "\n") {
|
||||
t.Fatalf("expected single-line envelope, got multiple lines:\n%s", outStr)
|
||||
}
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
SessionID string `json:"session_id"`
|
||||
AgentID string `json:"agent_id"`
|
||||
Query string `json:"query"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(outStr), &env); err != nil {
|
||||
t.Fatalf("envelope not JSON: %v\n%s", err, outStr)
|
||||
}
|
||||
if !env.OK {
|
||||
t.Error("ok=false, want true")
|
||||
}
|
||||
if len(env.Data.Events) != 2 {
|
||||
t.Fatalf("events=%+v, want two answer frames", env.Data.Events)
|
||||
}
|
||||
for i, event := range env.Data.Events {
|
||||
if event.ResponseType != "answer" {
|
||||
t.Errorf("events[%d].response_type=%q, want answer", i, event.ResponseType)
|
||||
}
|
||||
}
|
||||
if env.Data.Events[0].Content != "the answer" {
|
||||
t.Errorf("answer content=%q", env.Data.Events[0].Content)
|
||||
}
|
||||
if env.Data.AgentID != "ag_x" || env.Data.Query != "hi" {
|
||||
t.Errorf("echo fields: agent_id=%q query=%q", env.Data.AgentID, env.Data.Query)
|
||||
}
|
||||
if env.Data.SessionID == "" {
|
||||
t.Error("session_id empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_FormatJSON_VerboseAndReferenceIncludeBothDetailClasses(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
{ID: "think", ResponseType: sdk.AgentResponseTypeThinking, Content: "reasoning", Done: true},
|
||||
toolCallEvent("call_1", "knowledge_search"),
|
||||
referencesEvent([]*sdk.SearchResult{{ID: "c1", KnowledgeBaseID: "kb1", ParentChunkID: "p1", Content: "BULK"}}),
|
||||
answerEvent("answer [chunk:c1]"),
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi", Verbose: true, Reference: true}
|
||||
if err := runAsk(context.Background(), opts, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
var env struct {
|
||||
Data struct {
|
||||
Events []sse.ProjectedEvent `json:"events"`
|
||||
} `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(out.Bytes(), &env); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []string{"thinking", "tool_call", "references", "answer", "complete"}
|
||||
if len(env.Data.Events) != len(want) {
|
||||
t.Fatalf("events=%+v", env.Data.Events)
|
||||
}
|
||||
for i, responseType := range want {
|
||||
if env.Data.Events[i].ResponseType != responseType {
|
||||
t.Errorf("events[%d]=%q, want %q", i, env.Data.Events[i].ResponseType, responseType)
|
||||
}
|
||||
}
|
||||
refs := env.Data.Events[2].KnowledgeReferences
|
||||
if len(refs) != 1 || refs[0].KBID != "kb1" || refs[0].ChunkID != "c1" || refs[0].ParentChunkID != "p1" {
|
||||
t.Errorf("reference indexes=%+v", refs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_Text_VerboseIncludesThinking verifies the --format text path
|
||||
// honors --verbose: the agent's thinking streams inline with the answer.
|
||||
// (Regresses the original bug: runAskText ignored opts.Verbose entirely, so
|
||||
// thinking never appeared in text mode under any flag.)
|
||||
func TestSessionAsk_Text_VerboseIncludesThinking(t *testing.T) {
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "REASONING"},
|
||||
answerEvent("answer"),
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi", Verbose: true}
|
||||
if err := runAsk(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
if !strings.Contains(out.String(), "REASONING") {
|
||||
t.Errorf("verbose text output missing thinking: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "answer") {
|
||||
t.Errorf("answer body missing: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_Text_NonTTYVerboseIncludesThinking(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "REASONING", Done: true},
|
||||
answerEvent("answer"),
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi", Verbose: true}
|
||||
if err := runAsk(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
if got := out.String(); !strings.Contains(got, "REASONING") || !strings.Contains(got, "answer") {
|
||||
t.Errorf("non-TTY verbose output missing thinking or answer: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_Text_HidesThinkingByDefault: without --verbose the text path
|
||||
// must NOT stream the reasoning pass, only the answer.
|
||||
func TestSessionAsk_Text_HidesThinkingByDefault(t *testing.T) {
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "REASONING"},
|
||||
answerEvent("answer"),
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi"}
|
||||
if err := runAsk(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
if strings.Contains(out.String(), "REASONING") {
|
||||
t.Errorf("non-verbose text output leaked thinking: %q", out.String())
|
||||
}
|
||||
if !strings.Contains(out.String(), "answer") {
|
||||
t.Errorf("answer body missing: %q", out.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_AutoCreatedSessionID_PassedAsAgentRequest checks the session id
|
||||
// flows from auto-create through to the SDK stream call.
|
||||
func TestSessionAsk_AutoCreatedSessionID_PassedAsAgentRequest(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{doneEvent()}}
|
||||
opts := &AskOptions{AgentID: "ag_42", Query: "x"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
if svc.got.sessionID != "sess_auto" {
|
||||
t.Errorf("agent-chat got sessionID=%q, want sess_auto", svc.got.sessionID)
|
||||
}
|
||||
if svc.got.req == nil || svc.got.req.AgentID != "ag_42" {
|
||||
t.Errorf("AgentID not forwarded: %+v", svc.got.req)
|
||||
}
|
||||
if !svc.got.req.AgentEnabled {
|
||||
t.Error("AgentEnabled must be true for session ask")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_ExistingSessionID_SkipsCreate(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
created := false
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{doneEvent()}}
|
||||
// Wrap CreateSession to detect call.
|
||||
svc.createResp = &sdk.Session{ID: "should_not_be_used"}
|
||||
wrapped := &createSessionTracker{AskService: svc, called: &created}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x", SessionID: "sess_existing"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), wrapped); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
if created {
|
||||
t.Error("CreateSession should not be called when --session is set")
|
||||
}
|
||||
if svc.got.sessionID != "sess_existing" {
|
||||
t.Errorf("agent-chat got sessionID=%q, want sess_existing", svc.got.sessionID)
|
||||
}
|
||||
}
|
||||
|
||||
type createSessionTracker struct {
|
||||
AskService
|
||||
called *bool
|
||||
}
|
||||
|
||||
func (c *createSessionTracker) CreateSession(ctx context.Context, req *sdk.CreateSessionRequest) (*sdk.Session, error) {
|
||||
*c.called = true
|
||||
return c.AskService.CreateSession(ctx, req)
|
||||
}
|
||||
|
||||
// TestSessionAsk_EmptyQuery_Rejected checks validation fires before any SDK call.
|
||||
func TestSessionAsk_EmptyQuery_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{}
|
||||
opts := &AskOptions{AgentID: "ag", Query: ""}
|
||||
err := runAsk(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected input.invalid_argument, got nil")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeInputInvalidArgument {
|
||||
t.Errorf("expected input.invalid_argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_StreamAbortBeforeDone_MapsToSSEStreamAborted uses the human path
|
||||
// because the NDJSON path does not buffer/validate Done events.
|
||||
func TestSessionAsk_StreamAbortBeforeDone_MapsToSSEStreamAborted(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
answerEvent("partial"),
|
||||
},
|
||||
streamErr: errors.New("connection reset"),
|
||||
}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x"}
|
||||
// Text path (textOpts) validates Done; NDJSON path does not buffer.
|
||||
err := runAsk(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected stream-aborted error")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeSSEStreamAborted {
|
||||
t.Errorf("expected local.sse_stream_aborted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_NoDoneEvent_MapsToSSEStreamAborted uses the human path
|
||||
// because the NDJSON path does not validate Done events.
|
||||
func TestSessionAsk_NoDoneEvent_MapsToSSEStreamAborted(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{answerEvent("incomplete")}}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x"}
|
||||
err := runAsk(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected stream-aborted error")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeSSEStreamAborted {
|
||||
t.Errorf("expected local.sse_stream_aborted, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_FormatJSON_StreamErrorIncludesSessionDetail(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{streamErr: errors.New("connection reset")}
|
||||
err := runAsk(
|
||||
context.Background(),
|
||||
&AskOptions{AgentID: "ag", Query: "x"},
|
||||
jsonOpts(),
|
||||
svc,
|
||||
)
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) {
|
||||
t.Fatalf("expected *cmdutil.Error, got %v", err)
|
||||
}
|
||||
detail, ok := typed.Detail.(map[string]any)
|
||||
if !ok || detail["session_id"] != "sess_auto" {
|
||||
t.Errorf("error detail=%v, want auto-created session_id", typed.Detail)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_CreateSessionFails_MapsToSessionCreateFailed(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{createErr: errors.New("connection refused")}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x"}
|
||||
err := runAsk(context.Background(), opts, textOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected session_create_failed")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeSessionCreateFailed {
|
||||
t.Errorf("expected server.session_create_failed, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel() // pre-cancel
|
||||
svc := &scriptedAskSvc{streamErr: context.Canceled}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x"}
|
||||
// NDJSON path also handles cancellation correctly.
|
||||
err := runAsk(ctx, opts, ndjsonOpts(), svc)
|
||||
if err == nil {
|
||||
t.Fatal("expected operation.cancelled")
|
||||
}
|
||||
var typed *cmdutil.Error
|
||||
if !errors.As(err, &typed) || typed.Code != cmdutil.CodeOperationCancelled {
|
||||
t.Errorf("expected operation.cancelled, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Default text writes answer events but filters tool events.
|
||||
func TestSessionAsk_Text_DefaultHidesTools(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{events: []*sdk.AgentStreamResponse{
|
||||
answerEvent("hello"),
|
||||
toolCallEvent("c1", "knowledge_search"),
|
||||
doneEvent(),
|
||||
}}
|
||||
opts := &AskOptions{AgentID: "ag", Query: "x"}
|
||||
if err := runAsk(context.Background(), opts, textOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
if !strings.Contains(got, "hello") {
|
||||
t.Errorf("answer body missing: %q", got)
|
||||
}
|
||||
if strings.Contains(got, "tool_call") || strings.Contains(got, "knowledge_search") {
|
||||
t.Errorf("default text output leaked tool event: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_FormatNDJSON_PassthroughsSDKEvents verifies:
|
||||
// 1 init line + N SDK events = N+1 total lines; first is init, rest are SDK events.
|
||||
func TestSessionAsk_FormatNDJSON_PassthroughsSDKEvents(t *testing.T) {
|
||||
// Fake stream emits 3 events: tool_call, answer, done.
|
||||
// With the init injection, total output is 4 lines (1 init + 3 SDK events).
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
toolCallEvent("call_1", "knowledge_search"),
|
||||
answerEvent("hello"),
|
||||
doneEvent(),
|
||||
},
|
||||
}
|
||||
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi"}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatNDJSON}
|
||||
if err := runAsk(context.Background(), opts, fopts, svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 3 SDK events = 4 lines.
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d lines, want 4:\n%s", len(lines), out.String())
|
||||
}
|
||||
// Each line must be valid JSON.
|
||||
for i, line := range lines {
|
||||
var obj map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &obj); err != nil {
|
||||
t.Fatalf("line %d not valid JSON: %v\n %s", i+1, err, line)
|
||||
}
|
||||
}
|
||||
|
||||
// First line: CLI-injected init event.
|
||||
var initLine map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &initLine); err != nil {
|
||||
t.Fatalf("line 1 (init) not JSON: %v", err)
|
||||
}
|
||||
if initLine["type"] != "init" {
|
||||
t.Errorf("first line type=%v, want init", initLine["type"])
|
||||
}
|
||||
if initLine["agent_id"] != "ag_x" {
|
||||
t.Errorf("first line agent_id=%v, want ag_x", initLine["agent_id"])
|
||||
}
|
||||
|
||||
// Second line: tool_call event (SDK passthrough).
|
||||
var second map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[1]), &second); err != nil {
|
||||
t.Fatalf("line 2 not JSON: %v", err)
|
||||
}
|
||||
if second["response_type"] != string(sdk.AgentResponseTypeToolCall) {
|
||||
t.Errorf("second event response_type=%v, want %s", second["response_type"], sdk.AgentResponseTypeToolCall)
|
||||
}
|
||||
// Third line: answer event.
|
||||
var third map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[2]), &third); err != nil {
|
||||
t.Fatalf("line 3 not JSON: %v", err)
|
||||
}
|
||||
if third["response_type"] != string(sdk.AgentResponseTypeAnswer) {
|
||||
t.Errorf("third event response_type=%v, want %s", third["response_type"], sdk.AgentResponseTypeAnswer)
|
||||
}
|
||||
// Fourth line: done event.
|
||||
var fourth map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[3]), &fourth); err != nil {
|
||||
t.Fatalf("line 4 not JSON: %v", err)
|
||||
}
|
||||
if fourth["done"] != true {
|
||||
t.Errorf("fourth event done=%v, want true", fourth["done"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_RequiresAgentFlag(t *testing.T) {
|
||||
// Build the real cobra command with a nil factory — flag parsing happens
|
||||
// before RunE so the factory is never dereferenced for this test.
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdAsk(f)
|
||||
// Redirect output to discard cobra error messages.
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
// Execute without --agent: cobra should refuse with exit-code 2.
|
||||
cmd.SetArgs([]string{"some question"})
|
||||
err := cmd.Execute()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when --agent is missing, got nil")
|
||||
}
|
||||
// Cobra wraps required-flag errors; the message should mention the flag.
|
||||
if !strings.Contains(err.Error(), "agent") {
|
||||
t.Errorf("error should mention 'agent' flag, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSessionAsk_NDJSON_IncludesReferencesViaSDKEvent verifies that references
|
||||
// emitted by the SDK appear as passthrough NDJSON lines (not lost).
|
||||
func TestSessionAsk_NDJSON_IncludesReferencesViaSDKEvent(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
answerEvent("Hello world."),
|
||||
referencesEvent([]*sdk.SearchResult{{KnowledgeID: "k1", KnowledgeTitle: "Doc 1"}}),
|
||||
doneEvent(),
|
||||
},
|
||||
}
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "ping"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 3 SDK events = 4 lines.
|
||||
if len(lines) != 4 {
|
||||
t.Fatalf("got %d NDJSON lines, want 4:\n%s", len(lines), out.String())
|
||||
}
|
||||
// init line.
|
||||
var init map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[0]), &init); err != nil {
|
||||
t.Fatalf("line 0 not JSON: %v", err)
|
||||
}
|
||||
if init["type"] != "init" {
|
||||
t.Errorf("line 0 type: got %v, want init", init["type"])
|
||||
}
|
||||
// references line is the third SDK event (lines[3] = index 3).
|
||||
var refsLine map[string]any
|
||||
if err := json.Unmarshal([]byte(lines[2]), &refsLine); err != nil {
|
||||
t.Fatalf("references line not JSON: %v", err)
|
||||
}
|
||||
if refsLine["response_type"] != string(sdk.AgentResponseTypeReferences) {
|
||||
t.Errorf("expected references event at line 3, got response_type=%v", refsLine["response_type"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestSessionAsk_NDJSON_PreservesReferencesAndThinking(t *testing.T) {
|
||||
// NDJSON is the raw protocol surface: reasoning and full reference payloads
|
||||
// pass through unchanged. Index projection is limited to JSON/text/MCP.
|
||||
svc := &scriptedAskSvc{
|
||||
events: []*sdk.AgentStreamResponse{
|
||||
{ResponseType: sdk.AgentResponseTypeThinking, Content: "reasoning"},
|
||||
referencesEvent([]*sdk.SearchResult{{ID: "c1", Content: "BULKY PASSAGE", KnowledgeTitle: "Doc"}}),
|
||||
answerEvent("hello"),
|
||||
doneEvent(),
|
||||
},
|
||||
}
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
|
||||
opts := &AskOptions{AgentID: "ag_x", Query: "hi"}
|
||||
if err := runAsk(context.Background(), opts, ndjsonOpts(), svc); err != nil {
|
||||
t.Fatalf("runAsk: %v", err)
|
||||
}
|
||||
|
||||
var refsLine map[string]any
|
||||
sawThinking := false
|
||||
for _, line := range strings.Split(strings.TrimRight(out.String(), "\n"), "\n") {
|
||||
var ev map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &ev); err != nil {
|
||||
continue
|
||||
}
|
||||
if ev["response_type"] == "thinking" {
|
||||
sawThinking = true
|
||||
}
|
||||
if ev["response_type"] == "references" {
|
||||
refsLine = ev
|
||||
}
|
||||
}
|
||||
if !sawThinking {
|
||||
t.Error("thinking event was filtered from raw NDJSON output")
|
||||
}
|
||||
if refsLine == nil {
|
||||
t.Fatal("references event not emitted")
|
||||
}
|
||||
refs, _ := refsLine["knowledge_references"].([]any)
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("knowledge_references=%d, want 1", len(refs))
|
||||
}
|
||||
first, _ := refs[0].(map[string]any)
|
||||
if first["content"] != "BULKY PASSAGE" {
|
||||
t.Errorf("references[0].content=%v, want original content", first["content"])
|
||||
}
|
||||
if first["id"] != "c1" {
|
||||
t.Errorf("references[0].id=%v, want c1", first["id"])
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
package sessioncmd
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
// sessionDeleteFields enumerates the fields surfaced for `--format json`
|
||||
// discovery on `session delete`. Tracks the single-id result struct;
|
||||
// multi-id mode emits the batch envelope.
|
||||
var sessionDeleteFields = []string{"id", "deleted"}
|
||||
|
||||
type DeleteOptions struct {
|
||||
Yes bool // sourced from the global -y/--yes persistent flag
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// DeleteService is the narrow SDK surface this command depends on.
|
||||
type DeleteService interface {
|
||||
DeleteSession(ctx context.Context, id string) error
|
||||
}
|
||||
|
||||
// deleteResult is the typed payload emitted under data on single-id success.
|
||||
type deleteResult struct {
|
||||
ID string `json:"id"`
|
||||
Deleted bool `json:"deleted"`
|
||||
}
|
||||
|
||||
// NewCmdDelete builds `weknora session delete`. Single-id keeps the simpler
|
||||
// code path; multi-id uses keep-going semantics (one -y confirms all,
|
||||
// failures collected, exit 1 if any fail).
|
||||
func NewCmdDelete(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &DeleteOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete <session-id> [<session-id>...]",
|
||||
Short: "Delete one or more chat sessions",
|
||||
Long: `Permanently delete one or more chat sessions and their messages.
|
||||
|
||||
Prompts for confirmation by default when stdout is a TTY and JSON output is
|
||||
not set. Pass -y/--yes (global flag) to skip the prompt (required in agent
|
||||
/ CI / piped contexts).
|
||||
|
||||
Single-id: one confirm prompt, exit 0/1.
|
||||
Multi-id:
|
||||
• Default keep-going: failed deletes do NOT stop the run; failures collected.
|
||||
• One -y/--yes confirms all sessions.
|
||||
• Exit 0 if all succeed; exit 1 if any failed.
|
||||
|
||||
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.`,
|
||||
Example: ` weknora session delete s_abc # interactive confirm
|
||||
weknora session delete s_abc -y # no prompt
|
||||
weknora session delete s_abc -y --format json # bare {id, deleted:true} JSON
|
||||
weknora session delete s_a s_b s_c -y # delete 3, keep-going`,
|
||||
Args: cobra.MinimumNArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "session.delete",
|
||||
Args: map[string]any{
|
||||
"session_ids": args,
|
||||
},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// Single-id uses the simpler code path (bare {id, deleted}).
|
||||
if len(args) == 1 {
|
||||
return runDelete(c.Context(), opts, fopts, cli, f.Prompter(), args[0])
|
||||
}
|
||||
batchRetry := append([]string{"weknora", "session", "delete"}, args...)
|
||||
batchRetry = append(batchRetry, "-y")
|
||||
if err := cmdutil.ConfirmDestructiveBatch(f.Prompter(), opts.Yes, fopts.WantsJSON(), "delete", "session", len(args), "session.delete", batchRetry); err != nil {
|
||||
return err
|
||||
}
|
||||
outcomes, runErr := cmdutil.RunBatch(c.Context(), args, func(ctx context.Context, id string) error {
|
||||
if err := cli.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete session %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
// Only emit when the operation actually ran. Pre-flight errors
|
||||
// (e.g. confirmation_required) must leave stdout empty per the
|
||||
// wire contract in README.md.
|
||||
if len(outcomes) > 0 {
|
||||
if emitErr := cmdutil.EmitBatch(outcomes, fopts, iostreams.IO.Out, cmdutil.DeletedAtNow); emitErr != nil {
|
||||
return emitErr
|
||||
}
|
||||
}
|
||||
return runErr
|
||||
},
|
||||
}
|
||||
cmdutil.AddFormatFlag(cmd, sessionDeleteFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "session.delete")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "permanently delete one or more chat sessions and their messages",
|
||||
RequiredFlags: []string{"<session-id>... (positional, at least one)"},
|
||||
Examples: []string{
|
||||
"weknora session delete s_abc -y",
|
||||
"weknora session delete s_a s_b s_c -y",
|
||||
"weknora session delete s_abc -y --format json",
|
||||
},
|
||||
Output: "envelope.data shape depends on the form: a single session id -> {id, deleted:true}; multiple ids -> batch envelope (top-level status success|partial|error, ok=(failures==0), data per-item [{id, ok, error?}], meta.successes/failures — read data[].ok per id).",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"session delete is irreversible; loses the conversation + all messages + tool call history.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func runDelete(ctx context.Context, opts *DeleteOptions, fopts *cmdutil.FormatOptions, svc DeleteService, p prompt.Prompter, id string) error {
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), "delete", "session", id, "session.delete", []string{"weknora", "session", "delete", id, "-y"}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := svc.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete session %s", id)
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, deleteResult{ID: id, Deleted: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Deleted session %s\n", id)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
)
|
||||
|
||||
// fakeDeleteSvc records what id was deleted.
|
||||
type fakeDeleteSvc struct {
|
||||
err error
|
||||
gotID string
|
||||
called bool
|
||||
}
|
||||
|
||||
func (f *fakeDeleteSvc) DeleteSession(_ context.Context, id string) error {
|
||||
f.called = true
|
||||
f.gotID = id
|
||||
return f.err
|
||||
}
|
||||
|
||||
func TestDelete_WithYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "s_abc"))
|
||||
assert.True(t, svc.called)
|
||||
assert.Equal(t, "s_abc", svc.gotID)
|
||||
assert.False(t, p.Asked, "-y must skip prompt")
|
||||
assert.Contains(t, out.String(), "Deleted")
|
||||
}
|
||||
|
||||
func TestDelete_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runDelete(context.Background(), &DeleteOptions{Yes: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "s_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
iostreams.SetForTest(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, &testutil.ConfirmPrompter{}, "s_x")
|
||||
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))
|
||||
assert.False(t, svc.called, "non-TTY without -y must not call DeleteSession")
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmYes(t *testing.T) {
|
||||
_, _ = iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "s_yes"))
|
||||
assert.True(t, p.Asked)
|
||||
assert.True(t, svc.called)
|
||||
}
|
||||
|
||||
func TestDelete_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeDeleteSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runDelete(context.Background(), &DeleteOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, p, "s_no")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeUserAborted, typed.Code)
|
||||
assert.False(t, svc.called)
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Multi-id (keep-going semantics)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// fakeMultiDeleteSvc records every id deleted and can fail-on selected ids.
|
||||
type fakeMultiDeleteSvc struct {
|
||||
deleted []string
|
||||
failOn map[string]error
|
||||
}
|
||||
|
||||
func (f *fakeMultiDeleteSvc) DeleteSession(_ context.Context, id string) error {
|
||||
if e, ok := f.failOn[id]; ok {
|
||||
return e
|
||||
}
|
||||
f.deleted = append(f.deleted, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestMultiDelete_AllSucceed(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiDeleteSvc{}
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"s_a", "s_b", "s_c"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete session %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, outcomes, 3)
|
||||
for _, oc := range outcomes {
|
||||
assert.Nil(t, oc.Err, "expected no error for %s", oc.ID)
|
||||
}
|
||||
assert.Equal(t, []string{"s_a", "s_b", "s_c"}, svc.deleted)
|
||||
}
|
||||
|
||||
func TestMultiDelete_PartialFailure_KeepsGoing(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiDeleteSvc{failOn: map[string]error{"s_b": errors.New("HTTP error 404: not found")}}
|
||||
outcomes, err := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"s_a", "s_b", "s_c"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete session %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, err, "any-failed must surface CodeOperationFailed")
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeOperationFailed, typed.Code)
|
||||
require.Len(t, outcomes, 3, "all three ids must appear in outcomes (argv order preserved)")
|
||||
assert.Nil(t, outcomes[0].Err, "s_a should succeed")
|
||||
assert.NotNil(t, outcomes[1].Err, "s_b should fail")
|
||||
assert.Nil(t, outcomes[2].Err, "s_c should succeed (keep-going)")
|
||||
assert.Equal(t, "s_b", outcomes[1].ID)
|
||||
}
|
||||
|
||||
func TestMultiDelete_NonTTY_NoYes_RequiresConfirmation(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiDeleteSvc{}
|
||||
fopts := &cmdutil.FormatOptions{Mode: cmdutil.FormatText}
|
||||
err := cmdutil.ConfirmDestructiveBatch(&testutil.ConfirmPrompter{}, false, fopts.WantsJSON(), "delete", "session", 2, "session.delete", nil)
|
||||
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))
|
||||
assert.Empty(t, svc.deleted, "non-TTY without -y must not call DeleteSession")
|
||||
}
|
||||
|
||||
// TestSessionDelete_MultiID_PartialFailure_BatchEnvelope verifies that the JSON
|
||||
// output for a multi-id partial failure uses the batch envelope shape:
|
||||
// {ok:false, data:[{id,ok,result?|error?}...], meta:{count,successes,failures}}.
|
||||
func TestSessionDelete_MultiID_PartialFailure_BatchEnvelope(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeMultiDeleteSvc{failOn: map[string]error{
|
||||
"s_b": errors.New("HTTP error 404: not found"),
|
||||
}}
|
||||
outcomes, runErr := cmdutil.RunBatch(context.Background(),
|
||||
[]string{"s_a", "s_b", "s_c"},
|
||||
func(ctx context.Context, id string) error {
|
||||
if err := svc.DeleteSession(ctx, id); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "delete session %s", id)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
require.Error(t, runErr)
|
||||
require.Len(t, outcomes, 3)
|
||||
|
||||
var buf bytes.Buffer
|
||||
require.NoError(t, cmdutil.EmitBatch(outcomes, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, &buf, cmdutil.DeletedAtNow))
|
||||
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
OK bool `json:"ok"`
|
||||
Error *struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error,omitempty"`
|
||||
} `json:"data"`
|
||||
Meta struct {
|
||||
Count int `json:"count"`
|
||||
Successes int `json:"successes"`
|
||||
Failures int `json:"failures"`
|
||||
} `json:"meta"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(buf.Bytes(), &env), "envelope must be valid JSON")
|
||||
|
||||
assert.False(t, env.OK, "top-level ok must be false when any item failed")
|
||||
assert.Equal(t, 3, env.Meta.Count)
|
||||
assert.Equal(t, 2, env.Meta.Successes)
|
||||
assert.Equal(t, 1, env.Meta.Failures)
|
||||
require.Len(t, env.Data, 3)
|
||||
assert.Equal(t, "s_a", env.Data[0].ID)
|
||||
assert.True(t, env.Data[0].OK)
|
||||
assert.Equal(t, "s_b", env.Data[1].ID)
|
||||
assert.False(t, env.Data[1].OK)
|
||||
assert.NotNil(t, env.Data[1].Error)
|
||||
assert.Equal(t, "s_c", env.Data[2].ID)
|
||||
assert.True(t, env.Data[2].OK)
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/text"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultPageSize = 30
|
||||
maxPageSize = 1000
|
||||
)
|
||||
|
||||
// sessionListFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `session list`. Mirrors sdk.Session json tags.
|
||||
var sessionListFields = []string{
|
||||
"id", "tenant_id", "title", "description", "created_at", "updated_at",
|
||||
}
|
||||
|
||||
type ListOptions struct {
|
||||
PageSize int // Items per server batch (default 50).
|
||||
Since string // --since: filter to sessions updated within the past duration
|
||||
// Limit caps the returned items client-side (default 30; 0 = no cap).
|
||||
// Applied after pagination / --all-pages accumulation and --since filter.
|
||||
Limit int
|
||||
// AllPages walks server pages internally, accumulating items until
|
||||
// total exhausted or --limit hit.
|
||||
AllPages bool
|
||||
}
|
||||
|
||||
// ListService is the narrow SDK surface this command depends on.
|
||||
type ListService interface {
|
||||
GetSessionsByTenant(ctx context.Context, page, pageSize int) ([]sdk.Session, int, error)
|
||||
}
|
||||
|
||||
// NewCmdList builds `weknora session list`.
|
||||
func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ListOptions{PageSize: defaultPageSize}
|
||||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List chat sessions for the active profile",
|
||||
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
|
||||
// --page-size/--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().IntVar(&opts.PageSize, "page-size", defaultPageSize, "Items per server batch (1..1000)")
|
||||
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.Since, "since", "", "Only show sessions updated within `duration` (e.g. 7d, 24h, 30m)")
|
||||
cmdutil.AddFormatFlag(cmd, sessionListFields...)
|
||||
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 returned count; meta.total_count is the server-side total before --since filtering; meta.has_more=true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateListOpts checks --page-size / --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.PageSize < 1 || opts.PageSize > maxPageSize {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--page-size must be in 1..%d, got %d", maxPageSize, opts.PageSize),
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
var since time.Duration
|
||||
if opts.Since != "" {
|
||||
d, err := parseSinceDuration(opts.Since)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
since = d
|
||||
}
|
||||
|
||||
var items []sdk.Session
|
||||
var serverTotal int
|
||||
if opts.AllPages {
|
||||
accum := make([]sdk.Session, 0)
|
||||
for page := 1; ; page++ {
|
||||
chunk, total, err := svc.GetSessionsByTenant(ctx, page, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list sessions")
|
||||
}
|
||||
serverTotal = total
|
||||
accum = append(accum, chunk...)
|
||||
if opts.Limit > 0 && len(accum) >= opts.Limit {
|
||||
accum = accum[:opts.Limit]
|
||||
break
|
||||
}
|
||||
if len(accum) >= total || len(chunk) == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
items = accum
|
||||
} else {
|
||||
chunk, total, err := svc.GetSessionsByTenant(ctx, 1, opts.PageSize)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "list sessions")
|
||||
}
|
||||
serverTotal = total
|
||||
items = chunk
|
||||
}
|
||||
if items == nil {
|
||||
items = []sdk.Session{} // JSON [] not null
|
||||
}
|
||||
if since > 0 {
|
||||
threshold := time.Now().Add(-since)
|
||||
filtered := items[:0]
|
||||
for _, s := range items {
|
||||
t, err := time.Parse(time.RFC3339, s.UpdatedAt)
|
||||
if err != nil {
|
||||
continue // skip unparseable timestamps rather than guess
|
||||
}
|
||||
if t.After(threshold) {
|
||||
filtered = append(filtered, s)
|
||||
}
|
||||
}
|
||||
items = filtered
|
||||
}
|
||||
// --limit applies after --since so the cap reflects what the user sees.
|
||||
truncated := false
|
||||
if opts.Limit > 0 && len(items) > opts.Limit {
|
||||
items = items[:opts.Limit]
|
||||
truncated = true
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
meta := &output.Meta{Count: output.IntPtr(len(items)), HasMore: truncated, TotalCount: output.IntPtr(serverTotal)}
|
||||
return fopts.Emit(iostreams.IO.Out, items, meta)
|
||||
}
|
||||
|
||||
if len(items) == 0 {
|
||||
fmt.Fprintln(iostreams.IO.Out, "(no sessions)")
|
||||
return nil
|
||||
}
|
||||
tw := tabwriter.NewWriter(iostreams.IO.Out, 0, 0, 2, ' ', 0)
|
||||
fmt.Fprintln(tw, "ID\tTITLE\tUPDATED")
|
||||
now := time.Now()
|
||||
for _, s := range items {
|
||||
title := text.Truncate(50, s.Title)
|
||||
if title == "" {
|
||||
title = "-"
|
||||
}
|
||||
fmt.Fprintf(tw, "%s\t%s\t%s\n", s.ID, title, text.FuzzyAgoStr(now, s.UpdatedAt))
|
||||
}
|
||||
return tw.Flush()
|
||||
}
|
||||
|
||||
// parseSinceDuration accepts `time.ParseDuration` forms (1h30m, 24h, 30m)
|
||||
// plus a `<N>d` suffix for whole days (e.g. 7d). Returns an
|
||||
// input.invalid_argument *cmdutil.Error for unparseable inputs or
|
||||
// non-positive durations.
|
||||
func parseSinceDuration(s string) (time.Duration, error) {
|
||||
raw := strings.TrimSpace(s)
|
||||
var d time.Duration
|
||||
var err error
|
||||
if rest, ok := strings.CutSuffix(raw, "d"); ok {
|
||||
num, perr := strconv.ParseFloat(rest, 64)
|
||||
if perr != nil {
|
||||
err = perr
|
||||
} else {
|
||||
d = time.Duration(num * float64(24*time.Hour))
|
||||
}
|
||||
} else {
|
||||
d, err = time.ParseDuration(raw)
|
||||
}
|
||||
if err != nil {
|
||||
return 0, &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--since %q is not a valid duration: %v (try 7d, 24h, 30m, 1h30m)", s, err),
|
||||
}
|
||||
}
|
||||
if d <= 0 {
|
||||
return 0, &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--since must be positive, got %q", s),
|
||||
}
|
||||
}
|
||||
return d, nil
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeListService scripts a GetSessionsByTenant response.
|
||||
type fakeListService struct {
|
||||
items []sdk.Session
|
||||
total int
|
||||
err error
|
||||
gotPage int
|
||||
gotPageSize int
|
||||
}
|
||||
|
||||
func (f *fakeListService) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) {
|
||||
f.gotPage = page
|
||||
f.gotPageSize = pageSize
|
||||
return f.items, f.total, f.err
|
||||
}
|
||||
|
||||
func TestList_Empty(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: nil, total: 0}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Contains(t, out.String(), "no sessions")
|
||||
}
|
||||
|
||||
func TestList_Table(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{
|
||||
{ID: "s_1", Title: "Design review", CreatedAt: "2026-05-10T09:00:00Z", UpdatedAt: "2026-05-12T14:00:00Z"},
|
||||
{ID: "s_2", Title: "RAG bug repro", CreatedAt: "2026-05-09T08:00:00Z", UpdatedAt: "2026-05-11T11:00:00Z"},
|
||||
},
|
||||
total: 2,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "s_1")
|
||||
assert.Contains(t, got, "Design review")
|
||||
assert.Contains(t, got, "s_2")
|
||||
assert.Equal(t, 1, svc.gotPage)
|
||||
assert.Equal(t, 30, svc.gotPageSize)
|
||||
}
|
||||
|
||||
func TestList_JSON_BareArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{
|
||||
{ID: "s_1", Title: "T1", UpdatedAt: "2026-05-12T14:00:00Z"},
|
||||
},
|
||||
total: 47,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 10, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
|
||||
// CLI always asks for page 1 of size --page-size; pagination is server-internal.
|
||||
assert.Equal(t, 1, svc.gotPage)
|
||||
assert.Equal(t, 10, svc.gotPageSize)
|
||||
body := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Session `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &env), "expected valid JSON envelope; got %q", body)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Contains(t, body, `"id":"s_1"`)
|
||||
assert.NotContains(t, body, `"_meta":`)
|
||||
assert.NotContains(t, body, `"has_more":`)
|
||||
}
|
||||
|
||||
func TestList_NilItems_RendersAsBareEmptyArray(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: nil, total: 0}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data []sdk.Session `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out.Bytes(), &env), "expected valid JSON envelope; got %q", out.String())
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Len(t, env.Data, 0, "expected empty data array")
|
||||
}
|
||||
|
||||
func TestList_BadPagination(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
cases := []struct {
|
||||
size int
|
||||
name string
|
||||
}{
|
||||
{0, "size < 1"},
|
||||
{1001, "size > max"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
err := runList(context.Background(), &ListOptions{PageSize: tc.size, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, &fakeListService{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_NetworkError_TypedCode(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeListService{err: errors.New("HTTP error 401: unauthenticated")}
|
||||
err := runList(context.Background(), &ListOptions{PageSize: 30, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeAuthUnauthenticated, typed.Code)
|
||||
}
|
||||
|
||||
// Sanity: title with multi-rune content (CJK) should not crash truncation.
|
||||
func TestList_NonASCIITitle(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: []sdk.Session{{ID: "s_zh", Title: strings.Repeat("中文", 50)}}, total: 1}
|
||||
require.NoError(t, runList(context.Background(), &ListOptions{PageSize: 30, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc))
|
||||
assert.Contains(t, out.String(), "s_zh")
|
||||
}
|
||||
|
||||
func TestList_SinceFilter_DropsOldSessions(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
now := time.Now()
|
||||
items := []sdk.Session{
|
||||
{ID: "recent", Title: "today", UpdatedAt: now.Add(-1 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "old", Title: "last month", UpdatedAt: now.Add(-30 * 24 * time.Hour).Format(time.RFC3339)},
|
||||
{ID: "yesterday", Title: "yday", UpdatedAt: now.Add(-23 * time.Hour).Format(time.RFC3339)},
|
||||
}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Since: "7d", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
||||
&fakeListService{items: items, total: 3}))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "recent")
|
||||
assert.Contains(t, got, "yesterday")
|
||||
assert.NotContains(t, got, "old", "30-day-old session should be filtered out by --since 7d")
|
||||
}
|
||||
|
||||
func TestList_SinceFilter_ParseDuration_Variants(t *testing.T) {
|
||||
cases := []string{"24h", "1h30m", "30m", "7d", "0.5d", "168h"}
|
||||
for _, v := range cases {
|
||||
t.Run(v, func(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Since: v, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
||||
&fakeListService{items: []sdk.Session{}, total: 0}),
|
||||
"--since=%q should parse", v)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_SinceFilter_RejectsInvalidDuration(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Since: "bogus", Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
||||
&fakeListService{items: []sdk.Session{}, total: 0})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestList_SinceFilter_RejectsNonPositive(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
for _, v := range []string{"0d", "0h", "-1h"} {
|
||||
err := runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Since: v, Limit: 30}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
||||
&fakeListService{items: []sdk.Session{}, total: 0})
|
||||
require.Error(t, err, "--since=%q should reject", v)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
}
|
||||
|
||||
// makeSessions builds N sessions with distinct IDs and descending UpdatedAt.
|
||||
func makeSessions(n int) []sdk.Session {
|
||||
base := time.Now()
|
||||
out := make([]sdk.Session, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out[i] = sdk.Session{
|
||||
ID: fmt.Sprintf("s_%03d", i),
|
||||
Title: fmt.Sprintf("title-%03d", i),
|
||||
UpdatedAt: base.Add(-time.Duration(i) * time.Minute).Format(time.RFC3339),
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// pagedSessionSvc returns server-paginated session results from a flat slice
|
||||
// and records page numbers requested.
|
||||
type pagedSessionSvc struct {
|
||||
all []sdk.Session
|
||||
calls []int
|
||||
pageSize int
|
||||
}
|
||||
|
||||
func (p *pagedSessionSvc) GetSessionsByTenant(_ context.Context, page, pageSize int) ([]sdk.Session, int, error) {
|
||||
p.calls = append(p.calls, page)
|
||||
p.pageSize = pageSize
|
||||
start := (page - 1) * pageSize
|
||||
if start >= len(p.all) {
|
||||
return []sdk.Session{}, len(p.all), nil
|
||||
}
|
||||
end := start + pageSize
|
||||
if end > len(p.all) {
|
||||
end = len(p.all)
|
||||
}
|
||||
return p.all[start:end], len(p.all), nil
|
||||
}
|
||||
|
||||
func TestList_Limit_LessThanPageSize_SlicesToLimit(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: makeSessions(20), total: 20}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 20, Limit: 5}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
got := strings.Count(out.String(), `"id":"s_`)
|
||||
assert.Equal(t, 5, got, "--limit 5 must slice 20 items down to 5")
|
||||
}
|
||||
|
||||
func TestList_Limit_GreaterThanPageSize_NoCap(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: makeSessions(10), total: 10}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 10, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
got := strings.Count(out.String(), `"id":"s_`)
|
||||
assert.Equal(t, 10, got)
|
||||
}
|
||||
|
||||
func TestList_Limit_Negative_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Limit: -1}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText},
|
||||
&fakeListService{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestList_AllPages_WalksAllServerPages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedSessionSvc{all: makeSessions(45)}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 20, AllPages: true, Limit: 10000}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
assert.Equal(t, []int{1, 2, 3}, svc.calls)
|
||||
got := strings.Count(out.String(), `"id":"s_`)
|
||||
assert.Equal(t, 45, got)
|
||||
}
|
||||
|
||||
func TestList_AllPages_WithLimit_StopsAtLimit(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedSessionSvc{all: makeSessions(200)}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 20, AllPages: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
got := strings.Count(out.String(), `"id":"s_`)
|
||||
assert.Equal(t, 50, got)
|
||||
assert.LessOrEqual(t, len(svc.calls), 3, "must not fetch beyond what fills --limit")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_SinglePage asserts that meta.total_count is populated
|
||||
// from the server total when doing a single-page fetch.
|
||||
func TestList_JSON_TotalCount_SinglePage(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{
|
||||
items: []sdk.Session{{ID: "s_1", Title: "T1", UpdatedAt: "2026-05-12T14:00:00Z"}},
|
||||
total: 42,
|
||||
}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 10, Limit: 30},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":42`, "single-page fetch must surface server total in meta.total_count")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_AllPages asserts meta.total_count is populated
|
||||
// from the server total when walking all pages.
|
||||
func TestList_JSON_TotalCount_AllPages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &pagedSessionSvc{all: makeSessions(45)}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 20, AllPages: true, Limit: 10000},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":45`, "--all-pages fetch must surface server total in meta.total_count")
|
||||
}
|
||||
|
||||
// TestList_JSON_TotalCount_Zero_Present asserts that when server returns total=0
|
||||
// on an empty list, meta.total_count (and meta.count) still serialize as 0. The
|
||||
// *int + omitempty pattern omits only nil, so the agent contract stays stable:
|
||||
// an empty result reports 0, not a missing key.
|
||||
func TestList_JSON_TotalCount_Zero_Present(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeListService{items: nil, total: 0}
|
||||
require.NoError(t, runList(context.Background(),
|
||||
&ListOptions{PageSize: 30, Limit: 30},
|
||||
&cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"total_count":0`, "zero server total must serialize as 0 on empty list")
|
||||
assert.Contains(t, body, `"count":0`, "empty list must report count:0, not omit it")
|
||||
}
|
||||
@@ -0,0 +1,190 @@
|
||||
// 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.
|
||||
//
|
||||
// Server semantics:
|
||||
// - Replay-from-0 + tail: every connection replays the entire stored event
|
||||
// log from index 0, then tails any new events. NOT a cursor-from-disconnect
|
||||
// model. Agents that already consumed events on the original stream MUST
|
||||
// dedupe (by message_id + event hash, or by AssistantMessageID + content
|
||||
// fingerprint) to avoid double-processing.
|
||||
// - Buffer TTL:
|
||||
// redis mode - 1h hardcoded (not configurable from the CLI)
|
||||
// memory mode - process lifetime (server restart = data loss)
|
||||
// After TTL the server returns an error which the CLI maps to
|
||||
// local.sse_stream_aborted.
|
||||
//
|
||||
// Output shape matches `weknora chat` and `weknora session ask` NDJSON mode:
|
||||
// one CLI-injected init line carrying {session_id, message_id, profile} at
|
||||
// stream head, then SDK StreamResponse events verbatim. The init line lets
|
||||
// agents thread the resume to the original message in their dedupe table
|
||||
// without parsing the first SDK event.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// resumeFields enumerates the NDJSON init-event + raw SDK event
|
||||
// vocabulary surfaced for `--format json` / `--format ndjson` discovery.
|
||||
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
|
||||
}
|
||||
|
||||
// ResumeOptions captures `session resume` flag/arg state.
|
||||
type ResumeOptions struct {
|
||||
SessionID string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// 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 ResumeService interface {
|
||||
ContinueStream(ctx context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error
|
||||
}
|
||||
|
||||
// NewCmdResume builds `weknora session resume <session-id> --message <id>`.
|
||||
func NewCmdResume(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ResumeOptions{}
|
||||
cmd := &cobra.Command{
|
||||
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.
|
||||
|
||||
The server replays the entire stored event log for the given (session_id, message_id)
|
||||
from index 0, then tails any new events. This is NOT a cursor-from-disconnect
|
||||
model: agents that already consumed events on the original stream MUST dedupe
|
||||
(by message_id or event content hash) to avoid double-processing.
|
||||
|
||||
Buffer TTL:
|
||||
- redis mode: 1h hardcoded (not configurable from the CLI)
|
||||
- memory mode: process lifetime (server restart = data loss)
|
||||
|
||||
After TTL expiry the CLI surfaces local.sse_stream_aborted.
|
||||
|
||||
Typical use cases:
|
||||
- Network blip mid-stream: re-attach with the same session_id + message_id
|
||||
from the original 'session ask' / 'chat' init event.
|
||||
- Long-running agent invocation: poll progress without blocking the original
|
||||
stream.
|
||||
- Post-mortem inspection: replay the full event history of a completed
|
||||
message for debugging.
|
||||
|
||||
Output is NDJSON (matches 'chat' / 'session ask' --format json|ndjson):
|
||||
one init line at head ({session_id, message_id, profile}), then raw SDK
|
||||
StreamResponse events verbatim.
|
||||
|
||||
Note: unlike 'chat' / 'session ask', this command always emits NDJSON
|
||||
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 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]
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return 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, 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 (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 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
|
||||
}
|
||||
|
||||
// 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 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 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")
|
||||
}
|
||||
if opts.MessageID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--message cannot be empty")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session resume: no SDK client available")
|
||||
}
|
||||
|
||||
w := iostreams.IO.Out
|
||||
|
||||
// 1. Inject the CLI-managed init event at stream head. Both session_id
|
||||
// and message_id are populated so agents can key their dedupe table
|
||||
// on the resumed message before the first SDK frame arrives.
|
||||
initEv := output.InitEvent{
|
||||
SessionID: opts.SessionID,
|
||||
MessageID: opts.MessageID,
|
||||
Profile: cmdutil.GetProfile(),
|
||||
}
|
||||
if err := output.EmitInit(w, initEv); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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 {
|
||||
return output.EmitSDKEvent(w, r)
|
||||
}
|
||||
err := svc.ContinueStream(ctx, opts.SessionID, opts.MessageID, cb)
|
||||
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 resume cancelled")
|
||||
}
|
||||
// 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 ResumeService.
|
||||
var _ ResumeService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,217 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
sessionID string
|
||||
messageID string
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedResumeSvc) ContinueStream(_ context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error, opts ...sdk.ResourceURLOptions) error {
|
||||
s.got.sessionID = sessionID
|
||||
s.got.messageID = messageID
|
||||
for _, e := range s.events {
|
||||
if err := cb(e); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return s.streamErr
|
||||
}
|
||||
|
||||
func contStreamAnswer(content string) *sdk.StreamResponse {
|
||||
return &sdk.StreamResponse{ResponseType: sdk.ResponseTypeAnswer, Content: content}
|
||||
}
|
||||
func contStreamComplete() *sdk.StreamResponse {
|
||||
return &sdk.StreamResponse{ResponseType: sdk.ResponseTypeComplete, Done: true}
|
||||
}
|
||||
|
||||
// TestContinueStream_NDJSON_FirstLineIsInitWithMessageID verifies the
|
||||
// CLI-injected init line carries both session_id and message_id, so agents
|
||||
// can key dedupe tables on the resumed message before the first SDK frame
|
||||
// arrives.
|
||||
func TestContinueStream_NDJSON_FirstLineIsInitWithMessageID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{contStreamAnswer("hello"), contStreamComplete()},
|
||||
}
|
||||
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")
|
||||
|
||||
var first struct {
|
||||
Type string `json:"type"`
|
||||
SessionID string `json:"session_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(lines[0]), &first), "first line must be valid JSON: %q", lines[0])
|
||||
assert.Equal(t, "init", first.Type)
|
||||
assert.Equal(t, "sess_xyz", first.SessionID)
|
||||
assert.Equal(t, "msg_abc", first.MessageID, "init.message_id must echo --message (anchor for dedupe)")
|
||||
}
|
||||
|
||||
// TestContinueStream_NDJSON_PassthroughEvents verifies: 1 init line + N SDK
|
||||
// events = N+1 total lines, all valid JSON.
|
||||
func TestContinueStream_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{
|
||||
contStreamAnswer("alpha"),
|
||||
contStreamAnswer("beta"),
|
||||
contStreamComplete(),
|
||||
},
|
||||
}
|
||||
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.
|
||||
require.Equal(t, 4, len(lines), "expected init + 3 SDK events:\n%s", out.String())
|
||||
for i, line := range lines {
|
||||
var obj map[string]any
|
||||
assert.NoError(t, json.Unmarshal([]byte(line), &obj), "line %d not valid JSON: %q", i+1, line)
|
||||
}
|
||||
}
|
||||
|
||||
// TestContinueStream_PassesSessionAndMessageIDToSDK verifies the args + flag
|
||||
// flow through to the SDK call.
|
||||
func TestContinueStream_PassesSessionAndMessageIDToSDK(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
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)
|
||||
}
|
||||
|
||||
// TestContinueStream_EmptySessionID_Rejected guards the direct-test entry
|
||||
// point (cobra blocks empty positional via ExactArgs(1), but the runtime
|
||||
// core must also refuse empty strings).
|
||||
func TestContinueStream_EmptySessionID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
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)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
// TestContinueStream_EmptyMessageID_Rejected guards the direct-test entry
|
||||
// point.
|
||||
func TestContinueStream_EmptyMessageID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
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)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
// TestContinueStream_Cancellation_MapsToOperationCancelled verifies a
|
||||
// cancelled context maps to operation.cancelled (Ctrl-C lineage).
|
||||
func TestContinueStream_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
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)
|
||||
assert.Equal(t, cmdutil.CodeOperationCancelled, typed.Code)
|
||||
}
|
||||
|
||||
// TestContinueStream_NotFound_MapsToResourceNotFound verifies an SDK 404
|
||||
// (e.g. unknown message_id, or buffer expired past TTL) is classified by
|
||||
// the canonical HTTP classifier.
|
||||
func TestContinueStream_NotFound_MapsToResourceNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
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 := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.SetArgs([]string{"sess_xyz"}) // positional only, no --message
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected required-flag error when --message is missing")
|
||||
assert.True(t,
|
||||
strings.Contains(err.Error(), "message") || strings.Contains(err.Error(), "required"),
|
||||
"error should mention the required --message flag: %v", err)
|
||||
// Note: cobra's bare required-flag error here is not yet wrapped as
|
||||
// FlagError - that mapping happens in cmd/root.go for top-level Execute.
|
||||
// Asserting the message text is sufficient for this unit-level check;
|
||||
// exit-code mapping is covered by cmd/root tests.
|
||||
}
|
||||
|
||||
// TestContinueStream_RequiresSessionIDArg verifies cobra refuses to run the
|
||||
// command without the positional <session-id>.
|
||||
func TestContinueStream_RequiresSessionIDArg(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.SetArgs([]string{"--message", "msg_abc"}) // missing positional
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected ExactArgs(1) error when <session-id> is missing")
|
||||
// Cobra reports "accepts 1 arg(s), received 0" — assert on substance,
|
||||
// not exit-code mapping (the latter happens in cmd/root, see
|
||||
// TestContinueStream_RequiresMessageFlag note).
|
||||
assert.True(t,
|
||||
strings.Contains(err.Error(), "arg") || strings.Contains(err.Error(), "received"),
|
||||
"error should mention arg-count: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
// Package sessioncmd holds `weknora session` command tree (list / view /
|
||||
// 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
|
||||
// shadowing - same hygiene as `profilecmd`.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
)
|
||||
|
||||
// NewCmd builds the `weknora session` parent command.
|
||||
func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "session",
|
||||
Short: "Manage chat sessions",
|
||||
}
|
||||
cmd.AddCommand(NewCmdList(f))
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdAsk(f))
|
||||
cmd.AddCommand(NewCmdResume(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdToolApproval(f))
|
||||
return cmd
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
// stop.go implements `weknora session stop` — cancel server-side generation
|
||||
// for a specific assistant message under a known session.
|
||||
//
|
||||
// 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 resume`: both key on
|
||||
// (session_id, message_id). The message_id comes from the init event of the
|
||||
// original chat / session ask / resume stream.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
var stopFields = []string{"session_id", "message_id", "stopped"}
|
||||
|
||||
// StopOptions captures `session stop` flag/arg state.
|
||||
type StopOptions struct {
|
||||
SessionID string
|
||||
MessageID string
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// StopService 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 StopService interface {
|
||||
StopSession(ctx context.Context, sessionID, messageID string) error
|
||||
}
|
||||
|
||||
type stopResult struct {
|
||||
SessionID string `json:"session_id"`
|
||||
MessageID string `json:"message_id"`
|
||||
Stopped bool `json:"stopped"`
|
||||
}
|
||||
|
||||
// NewCmdStop builds `weknora session stop <session-id> --message <id>`.
|
||||
func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &StopOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "stop <session-id>",
|
||||
Short: "Stop in-flight generation for an assistant message in a session",
|
||||
Long: `Cancel server-side generation for a specific assistant message under a
|
||||
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 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 {
|
||||
opts.SessionID = args[0]
|
||||
fopts, err := cmdutil.CheckFormatFlag(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
fopts.ResolveDefault(iostreams.IO.IsStdoutTTY())
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "session.stop",
|
||||
Args: map[string]any{"session": opts.SessionID, "message": opts.MessageID},
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runStop(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.MessageID, "message", "m", "",
|
||||
"Assistant message id to stop (from the init event of the stream you're stopping)")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
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 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}",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runStop is the testable core: validate, dispatch the stop, and emit the
|
||||
// result envelope. Returns a typed error.
|
||||
func runStop(ctx context.Context, opts *StopOptions, fopts *cmdutil.FormatOptions, svc StopService) error {
|
||||
if opts.SessionID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "session-id argument cannot be empty")
|
||||
}
|
||||
if opts.MessageID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--message cannot be empty")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session stop: no SDK client available")
|
||||
}
|
||||
if err := svc.StopSession(ctx, opts.SessionID, opts.MessageID); err != nil {
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session stop cancelled")
|
||||
}
|
||||
return cmdutil.WrapHTTP(err, "stop session")
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, stopResult{SessionID: opts.SessionID, MessageID: opts.MessageID, Stopped: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ Stopped generation for message %s\n", opts.MessageID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: production SDK client satisfies StopService.
|
||||
var _ StopService = (*sdk.Client)(nil)
|
||||
@@ -0,0 +1,70 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeStopSvc records the (sessionID, messageID) pair passed to StopSession
|
||||
// and optionally returns a canned error.
|
||||
type fakeStopSvc struct {
|
||||
err error
|
||||
gotSessID string
|
||||
gotMsgID string
|
||||
}
|
||||
|
||||
func (s *fakeStopSvc) StopSession(_ context.Context, sessionID, messageID string) error {
|
||||
s.gotSessID = sessionID
|
||||
s.gotMsgID = messageID
|
||||
return s.err
|
||||
}
|
||||
|
||||
// TestRunStop_CallsSDKAndEmits verifies that runStop passes (sessionID,
|
||||
// messageID) to the SDK and emits a JSON envelope with stopped:true.
|
||||
func TestRunStop_CallsSDKAndEmits(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeStopSvc{}
|
||||
opts := &StopOptions{SessionID: "s1", MessageID: "m1"}
|
||||
require.NoError(t, runStop(context.Background(), opts, jsonOpts(), svc))
|
||||
|
||||
assert.Equal(t, "s1", svc.gotSessID, "SDK must receive the session-id")
|
||||
assert.Equal(t, "m1", svc.gotMsgID, "SDK must receive the message-id")
|
||||
assert.Contains(t, out.String(), `"stopped":true`, "stdout must contain stopped:true")
|
||||
}
|
||||
|
||||
// TestRunStop_TextMode verifies that runStop writes the human-readable success
|
||||
// line and calls the SDK when format is text (not JSON).
|
||||
func TestRunStop_TextMode(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeStopSvc{}
|
||||
opts := &StopOptions{SessionID: "sess_xyz", MessageID: "msg_abc"}
|
||||
require.NoError(t, runStop(context.Background(), opts, textOpts(), svc))
|
||||
|
||||
assert.Equal(t, "sess_xyz", svc.gotSessID, "SDK must receive the session-id")
|
||||
assert.Equal(t, "msg_abc", svc.gotMsgID, "SDK must receive the message-id")
|
||||
assert.Contains(t, out.String(), "✓ Stopped generation for message msg_abc")
|
||||
}
|
||||
|
||||
// TestStop_MessageRequired verifies that NewCmdStop refuses to run without
|
||||
// --message (the flag is marked required by cobra).
|
||||
func TestStop_MessageRequired(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdStop(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
cmd.SetArgs([]string{"sess_xyz"}) // positional only, no --message
|
||||
err := cmd.Execute()
|
||||
require.Error(t, err, "expected required-flag error when --message is missing")
|
||||
assert.True(t,
|
||||
strings.Contains(err.Error(), "message") || strings.Contains(err.Error(), "required"),
|
||||
"error should mention the required --message flag: %v", err)
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
var resolveFields = []string{"pending_id", "decision", "resolved"}
|
||||
|
||||
// decision values sent to the server and surfaced in the result/dry-run JSON.
|
||||
const (
|
||||
decisionApprove = "approve"
|
||||
decisionReject = "reject"
|
||||
)
|
||||
|
||||
// ResolveOptions holds the parsed flags and positional argument for
|
||||
// `session tool-approval resolve`.
|
||||
type ResolveOptions struct {
|
||||
PendingID string
|
||||
Reject bool
|
||||
Reason string
|
||||
ModifiedArgs string
|
||||
Yes bool
|
||||
DryRun bool
|
||||
}
|
||||
|
||||
// ResolveService is the narrow SDK surface this command depends on.
|
||||
type ResolveService interface {
|
||||
ResolveToolApproval(ctx context.Context, pendingID string, req *sdk.ResolveToolApprovalRequest) error
|
||||
}
|
||||
|
||||
type resolveResult struct {
|
||||
PendingID string `json:"pending_id"`
|
||||
Decision string `json:"decision"`
|
||||
Resolved bool `json:"resolved"`
|
||||
}
|
||||
|
||||
const resolveLong = `Resolve a pending tool approval raised during an agent run.
|
||||
|
||||
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 resume.
|
||||
|
||||
--modified-args replaces the tool call arguments on approve (JSON object).
|
||||
It conflicts with --reject (rejected calls never execute).
|
||||
|
||||
AI agents: approving a tool call is the human-approval step itself — that
|
||||
is exactly what the exit-10 protocol guards. Without -y/--yes the CLI
|
||||
exits 10 and writes input.confirmation_required to stderr. Surface the
|
||||
pending tool call to the user and only pass -y after their explicit
|
||||
go-ahead.`
|
||||
|
||||
// NewCmdToolApproval builds the `session tool-approval` subtree.
|
||||
func NewCmdToolApproval(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "tool-approval",
|
||||
Short: "Resolve pending tool approvals from agent runs",
|
||||
}
|
||||
cmd.AddCommand(newCmdResolve(f))
|
||||
return cmd
|
||||
}
|
||||
|
||||
func newCmdResolve(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ResolveOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "resolve <pending-id>",
|
||||
Short: "Approve or reject a pending tool call (high-risk write)",
|
||||
Long: resolveLong,
|
||||
Example: ` weknora session tool-approval resolve pend_abc -y # approve
|
||||
weknora session tool-approval resolve pend_abc --reject --reason "wrong target" -y
|
||||
weknora session tool-approval resolve pend_abc -y --format json`,
|
||||
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.PendingID = args[0]
|
||||
opts.Yes, _ = c.Flags().GetBool("yes")
|
||||
// Validate inputs BEFORE the dry-run gate so --dry-run previews a
|
||||
// valid action — an invalid --modified-args (e.g. "{}") must error
|
||||
// the same way whether or not --dry-run is passed.
|
||||
if _, err := validateModifiedArgs(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
dryRunArgs := map[string]any{
|
||||
"pending_id": opts.PendingID,
|
||||
"decision": decisionOf(opts.Reject),
|
||||
"reason": opts.Reason,
|
||||
}
|
||||
if opts.ModifiedArgs != "" {
|
||||
dryRunArgs["modified_args"] = opts.ModifiedArgs
|
||||
}
|
||||
if handled, err := cmdutil.HandleDryRun(c, opts.DryRun, cmdutil.DryRunPlan{
|
||||
Action: "session.tool_approval.resolve",
|
||||
Args: dryRunArgs,
|
||||
}); handled {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runResolve(c.Context(), opts, fopts, cli, f.Prompter())
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Reject, "reject", false, "Reject the pending tool call instead of approving")
|
||||
cmd.Flags().StringVar(&opts.Reason, "reason", "", "Reason recorded with the decision")
|
||||
cmd.Flags().StringVar(&opts.ModifiedArgs, "modified-args", "", "Replace tool call arguments on approve (JSON object; conflicts with --reject)")
|
||||
cmdutil.AddFormatFlag(cmd, resolveFields...)
|
||||
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 resume",
|
||||
RequiredFlags: []string{"<pending-id> (positional)"},
|
||||
Examples: []string{
|
||||
"weknora session tool-approval resolve pend_abc -y",
|
||||
"weknora session tool-approval resolve pend_abc --reject -y",
|
||||
},
|
||||
Output: "envelope.data is {pending_id, decision (approve|reject), resolved:true}",
|
||||
Warnings: []string{
|
||||
"Requires explicit user approval (exit 10 / input.confirmation_required); never auto-add -y.",
|
||||
"Approving executes the blocked tool call with the user's authority — show the user what tool/args are pending before resolving.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
func decisionOf(reject bool) string {
|
||||
if reject {
|
||||
return decisionReject
|
||||
}
|
||||
return decisionApprove
|
||||
}
|
||||
|
||||
// decisionPastTense returns the past tense of the decision verb for human output.
|
||||
func decisionPastTense(decision string) string {
|
||||
switch decision {
|
||||
case decisionReject:
|
||||
return "Rejected"
|
||||
default: // decisionApprove
|
||||
return "Approved"
|
||||
}
|
||||
}
|
||||
|
||||
// retryArgOf returns the extra --reject flag element to include in the retry
|
||||
// argv when the decision is reject; nil otherwise.
|
||||
func retryArgOf(reject bool) []string {
|
||||
if reject {
|
||||
return []string{"--reject"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateModifiedArgs checks the --reject/--modified-args invariants and
|
||||
// returns the validated raw JSON (nil when --modified-args is unset). Shared
|
||||
// by the RunE pre-dry-run gate and runResolve so both paths reject the same
|
||||
// inputs identically.
|
||||
func validateModifiedArgs(opts *ResolveOptions) (json.RawMessage, error) {
|
||||
if opts.Reject && opts.ModifiedArgs != "" {
|
||||
return nil, &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "--modified-args conflicts with --reject (rejected calls never execute)",
|
||||
}
|
||||
}
|
||||
if opts.ModifiedArgs == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var probe map[string]any
|
||||
if err := json.Unmarshal([]byte(opts.ModifiedArgs), &probe); err != nil || probe == nil {
|
||||
return nil, &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "--modified-args must be a non-null JSON object",
|
||||
}
|
||||
}
|
||||
if len(probe) == 0 {
|
||||
return nil, &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "--modified-args must not be an empty object (it would wipe the original tool arguments); omit the flag to keep them",
|
||||
}
|
||||
}
|
||||
return json.RawMessage(opts.ModifiedArgs), nil
|
||||
}
|
||||
|
||||
func runResolve(ctx context.Context, opts *ResolveOptions, fopts *cmdutil.FormatOptions, svc ResolveService, p prompt.Prompter) error {
|
||||
modified, err := validateModifiedArgs(opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
decision := decisionOf(opts.Reject)
|
||||
retryCmd := append([]string{"weknora", "session", "tool-approval", "resolve", opts.PendingID}, retryArgOf(opts.Reject)...)
|
||||
retryCmd = append(retryCmd, "-y")
|
||||
if err := cmdutil.ConfirmDestructive(p, opts.Yes, fopts.WantsJSON(), decision, "tool call", opts.PendingID, "session.tool_approval.resolve", retryCmd); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := svc.ResolveToolApproval(ctx, opts.PendingID, &sdk.ResolveToolApprovalRequest{
|
||||
Decision: decision,
|
||||
Reason: opts.Reason,
|
||||
ModifiedArgs: modified,
|
||||
}); err != nil {
|
||||
return cmdutil.WrapHTTP(err, "resolve tool approval %s", opts.PendingID)
|
||||
}
|
||||
if fopts.WantsJSON() {
|
||||
return fopts.Emit(iostreams.IO.Out, resolveResult{PendingID: opts.PendingID, Decision: decision, Resolved: true}, nil)
|
||||
}
|
||||
fmt.Fprintf(iostreams.IO.Out, "✓ %s tool call %s\n", decisionPastTense(decision), opts.PendingID)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/testutil"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
type fakeResolveSvc struct {
|
||||
err error
|
||||
gotPendingID string
|
||||
gotReq *sdk.ResolveToolApprovalRequest
|
||||
}
|
||||
|
||||
func (s *fakeResolveSvc) ResolveToolApproval(_ context.Context, pendingID string, req *sdk.ResolveToolApprovalRequest) error {
|
||||
s.gotPendingID, s.gotReq = pendingID, req
|
||||
return s.err
|
||||
}
|
||||
|
||||
func TestRunResolve_ApproveWithYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
opts := &ResolveOptions{PendingID: "p1", Yes: true}
|
||||
require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}))
|
||||
assert.Equal(t, "p1", svc.gotPendingID)
|
||||
assert.Equal(t, "approve", svc.gotReq.Decision)
|
||||
assert.Contains(t, out.String(), `"decision":"approve"`)
|
||||
}
|
||||
|
||||
func TestRunResolve_ApproveWithYes_ConfirmationRequiredCode(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
// Verify the "no-yes" path yields CodeInputConfirmationRequired.
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Yes: false}, 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.Empty(t, svc.gotPendingID, "SDK must not be called without confirmation")
|
||||
}
|
||||
|
||||
func TestRunResolve_RejectWithReason(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
opts := &ResolveOptions{PendingID: "p1", Reject: true, Reason: "wrong target", Yes: true}
|
||||
require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}))
|
||||
assert.Equal(t, "reject", svc.gotReq.Decision)
|
||||
assert.Equal(t, "wrong target", svc.gotReq.Reason)
|
||||
}
|
||||
|
||||
func TestRunResolve_NoYesIsConfirmationRequired(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, 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.Empty(t, svc.gotPendingID, "SDK must not be called without confirmation")
|
||||
}
|
||||
|
||||
func TestRunResolve_ModifiedArgsMustBeJSONObject(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", ModifiedArgs: `["not","an","object"]`, Yes: true}, jsonOpts(), &fakeResolveSvc{}, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "JSON object")
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
func TestRunResolve_RejectAndModifiedArgsConflict(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Reject: true, ModifiedArgs: `{"a":1}`, Yes: true}, jsonOpts(), &fakeResolveSvc{}, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
}
|
||||
|
||||
// TestRunResolve_TTY_ConfirmYes verifies that on a TTY with Answer=true the SDK is called.
|
||||
func TestRunResolve_TTY_ConfirmYes(t *testing.T) {
|
||||
out, _ := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: true}
|
||||
require.NoError(t, runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, textOpts(), svc, p))
|
||||
assert.True(t, p.Asked)
|
||||
assert.Equal(t, "p1", svc.gotPendingID)
|
||||
assert.Contains(t, out.String(), "Approved")
|
||||
}
|
||||
|
||||
// TestRunResolve_TTY_ConfirmNo verifies that on a TTY with Answer=false the SDK is NOT called.
|
||||
func TestRunResolve_TTY_ConfirmNo(t *testing.T) {
|
||||
_, errBuf := iostreams.SetForTestWithTTY(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
p := &testutil.ConfirmPrompter{Answer: false}
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1"}, textOpts(), 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.gotPendingID, "answer=false must not call SDK")
|
||||
assert.Contains(t, errBuf.String(), "Aborted")
|
||||
}
|
||||
|
||||
// TestRunResolve_ServiceError_Propagates verifies that SDK errors are wrapped and propagated.
|
||||
func TestRunResolve_ServiceError_Propagates(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{err: errors.New("HTTP error 404: not found")}
|
||||
err := runResolve(context.Background(), &ResolveOptions{PendingID: "p1", Yes: true}, jsonOpts(), svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestRunResolve_ModifiedArgs_LegalObject verifies that a valid JSON object is passed through to the SDK.
|
||||
func TestRunResolve_ModifiedArgs_LegalObject(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
opts := &ResolveOptions{PendingID: "p1", ModifiedArgs: `{"key":"val"}`, Yes: true}
|
||||
require.NoError(t, runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{}))
|
||||
require.NotNil(t, svc.gotReq.ModifiedArgs)
|
||||
var m map[string]any
|
||||
require.NoError(t, json.Unmarshal(svc.gotReq.ModifiedArgs, &m))
|
||||
assert.Equal(t, "val", m["key"])
|
||||
}
|
||||
|
||||
// TestRunResolve_ModifiedArgsEmptyObjectRejected verifies that {} is rejected before the SDK is called,
|
||||
// because an empty object would silently wipe the original tool arguments (downstream executor uses len>0 to decide replacement).
|
||||
func TestRunResolve_ModifiedArgsEmptyObjectRejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeResolveSvc{}
|
||||
opts := &ResolveOptions{PendingID: "p1", ModifiedArgs: `{}`, Yes: true}
|
||||
err := runResolve(context.Background(), opts, jsonOpts(), svc, &testutil.ConfirmPrompter{})
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, "empty object")
|
||||
assert.Empty(t, svc.gotPendingID, "SDK must not be called when --modified-args is an empty object")
|
||||
}
|
||||
|
||||
// TestValidateModifiedArgs covers the shared input validator that now runs
|
||||
// before the dry-run gate (so --dry-run --modified-args '{}' errors instead
|
||||
// of previewing an arg-wiping action). Each case mirrors a runResolve guard.
|
||||
func TestValidateModifiedArgs(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
opts *ResolveOptions
|
||||
wantErr string // substring; "" means expect success
|
||||
}{
|
||||
{"unset is ok", &ResolveOptions{}, ""},
|
||||
{"valid object", &ResolveOptions{ModifiedArgs: `{"path":"/x"}`}, ""},
|
||||
{"empty object rejected", &ResolveOptions{ModifiedArgs: `{}`}, "empty object"},
|
||||
{"array rejected", &ResolveOptions{ModifiedArgs: `[1,2]`}, "non-null JSON object"},
|
||||
{"null rejected", &ResolveOptions{ModifiedArgs: `null`}, "non-null JSON object"},
|
||||
{"reject+modified conflict", &ResolveOptions{Reject: true, ModifiedArgs: `{"a":1}`}, "conflicts with --reject"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
got, err := validateModifiedArgs(tc.opts)
|
||||
if tc.wantErr == "" {
|
||||
require.NoError(t, err)
|
||||
return
|
||||
}
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeInputInvalidArgument, typed.Code)
|
||||
assert.Contains(t, typed.Message, tc.wantErr)
|
||||
assert.Nil(t, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultFullLimit = 50
|
||||
maxFullLimit = 1000
|
||||
)
|
||||
|
||||
// sessionViewFields enumerates the fields surfaced for `--format json` discovery on
|
||||
// `session view`. Mirrors sdk.Session json tags; adds the synthesized
|
||||
// `messages` projection surfaced by `--full`.
|
||||
var sessionViewFields = []string{
|
||||
"id", "tenant_id", "title", "description", "created_at", "updated_at",
|
||||
"messages",
|
||||
}
|
||||
|
||||
type ViewOptions struct {
|
||||
// Full instructs runView to fetch chat history via LoadMessages and
|
||||
// render it after the session metadata.
|
||||
Full bool
|
||||
// Limit caps the number of messages loaded when Full is true.
|
||||
// Must be 1..maxFullLimit.
|
||||
Limit int
|
||||
// LimitSet records whether the caller explicitly set --limit, so we
|
||||
// can reject `--limit` without `--full` (vs. silently ignoring the
|
||||
// default).
|
||||
LimitSet bool
|
||||
}
|
||||
|
||||
// ViewService is the narrow SDK surface this command depends on. LoadMessages
|
||||
// is only invoked under --full but lives on the same interface so the runView
|
||||
// dependency surface stays minimal.
|
||||
type ViewService interface {
|
||||
GetSession(ctx context.Context, id string) (*sdk.Session, error)
|
||||
LoadMessages(ctx context.Context, sessionID string, limit int, beforeTime *time.Time, opts ...sdk.ResourceURLOptions) ([]sdk.Message, error)
|
||||
}
|
||||
|
||||
// NewCmdView builds `weknora session view <id>`. Renders session metadata
|
||||
// only by default. With `--full`, also loads the chat history via
|
||||
// `LoadMessages` and renders messages (or projects them into the JSON
|
||||
// payload under `messages`).
|
||||
func NewCmdView(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ViewOptions{Limit: defaultFullLimit}
|
||||
cmd := &cobra.Command{
|
||||
Use: "view <session-id>",
|
||||
Short: "Show a chat session by ID",
|
||||
Long: `Show a chat session.
|
||||
|
||||
By default renders the session metadata (id, title, description, timestamps).
|
||||
|
||||
Pass --full to also load the chat history (LoadMessages SDK call). Use
|
||||
--limit to cap the number of messages loaded (1..1000, default 50).
|
||||
--limit without --full is rejected as input.invalid_argument.`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.LimitSet = c.Flags().Changed("limit")
|
||||
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 := validateViewOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
cli, err := f.Client()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runView(c.Context(), opts, fopts, cli, args[0])
|
||||
},
|
||||
}
|
||||
cmd.Flags().BoolVar(&opts.Full, "full", false, "Also load chat history via LoadMessages")
|
||||
cmd.Flags().IntVarP(&opts.Limit, "limit", "L", defaultFullLimit, "Max messages to load when --full is set (1..1000)")
|
||||
cmdutil.AddFormatFlag(cmd, sessionViewFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "fetch one chat session by id; --full also loads its message history",
|
||||
RequiredFlags: []string{"<session-id> (positional)"},
|
||||
Examples: []string{"weknora session view sess_abc", "weknora session view sess_abc --full --limit 50"},
|
||||
Output: "envelope.data is the session object; with --full it also carries the loaded messages",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// validateViewOpts checks the --limit/--full invariants. Called from RunE
|
||||
// before the client is built (so a bad value surfaces as exit 5, not an auth
|
||||
// error) and at runView's top for direct callers; idempotent.
|
||||
func validateViewOpts(opts *ViewOptions) error {
|
||||
if !opts.Full && opts.LimitSet {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: "--limit requires --full",
|
||||
}
|
||||
}
|
||||
if opts.Full {
|
||||
if opts.Limit < 1 || opts.Limit > maxFullLimit {
|
||||
return &cmdutil.Error{
|
||||
Code: cmdutil.CodeInputInvalidArgument,
|
||||
Message: fmt.Sprintf("--limit must be in 1..%d, got %d", maxFullLimit, opts.Limit),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func runView(ctx context.Context, opts *ViewOptions, fopts *cmdutil.FormatOptions, svc ViewService, id string) error {
|
||||
if err := validateViewOpts(opts); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
s, err := svc.GetSession(ctx, id)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "get session %q", id)
|
||||
}
|
||||
|
||||
var msgs []sdk.Message
|
||||
if opts.Full {
|
||||
msgs, err = svc.LoadMessages(ctx, id, opts.Limit, nil)
|
||||
if err != nil {
|
||||
return cmdutil.WrapHTTP(err, "load messages for session %q", id)
|
||||
}
|
||||
if msgs == nil {
|
||||
msgs = []sdk.Message{}
|
||||
}
|
||||
}
|
||||
|
||||
if fopts.WantsJSON() {
|
||||
if !opts.Full {
|
||||
return fopts.Emit(iostreams.IO.Out, s, nil)
|
||||
}
|
||||
// Project session + messages into a single bare object. Use the
|
||||
// SDK json tags via an embedded *Session so existing keys stay
|
||||
// stable.
|
||||
payload := struct {
|
||||
*sdk.Session
|
||||
Messages []sdk.Message `json:"messages"`
|
||||
}{Session: s, Messages: msgs}
|
||||
return fopts.Emit(iostreams.IO.Out, payload, nil)
|
||||
}
|
||||
|
||||
w := iostreams.IO.Out
|
||||
fmt.Fprintf(w, "ID: %s\n", s.ID)
|
||||
if s.Title != "" {
|
||||
fmt.Fprintf(w, "TITLE: %s\n", s.Title)
|
||||
}
|
||||
if s.Description != "" {
|
||||
fmt.Fprintf(w, "DESC: %s\n", s.Description)
|
||||
}
|
||||
if t, ok := parseTS(s.CreatedAt); ok {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||
} else if s.CreatedAt != "" {
|
||||
fmt.Fprintf(w, "CREATED: %s\n", s.CreatedAt)
|
||||
}
|
||||
if t, ok := parseTS(s.UpdatedAt); ok {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", t.Format("2006-01-02 15:04:05"))
|
||||
} else if s.UpdatedAt != "" {
|
||||
fmt.Fprintf(w, "UPDATED: %s\n", s.UpdatedAt)
|
||||
}
|
||||
|
||||
if opts.Full {
|
||||
fmt.Fprintln(w)
|
||||
fmt.Fprintf(w, "Messages (%d):\n", len(msgs))
|
||||
for _, m := range msgs {
|
||||
fmt.Fprintln(w)
|
||||
ts := ""
|
||||
if !m.CreatedAt.IsZero() {
|
||||
ts = " " + m.CreatedAt.Format("2006-01-02 15:04:05")
|
||||
}
|
||||
fmt.Fprintf(w, "[%s]%s\n", m.Role, ts)
|
||||
if m.Content != "" {
|
||||
fmt.Fprintln(w, m.Content)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseTS(s string) (time.Time, bool) {
|
||||
if s == "" {
|
||||
return time.Time{}, false
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, s)
|
||||
if err != nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
return t, true
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"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"
|
||||
)
|
||||
|
||||
// fakeViewService scripts a GetSession + LoadMessages response.
|
||||
type fakeViewService struct {
|
||||
s *sdk.Session
|
||||
err error
|
||||
gotID string
|
||||
msgs []sdk.Message
|
||||
msgsErr error
|
||||
loadCall struct {
|
||||
sessionID string
|
||||
limit int
|
||||
called bool
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeViewService) GetSession(_ context.Context, id string) (*sdk.Session, error) {
|
||||
f.gotID = id
|
||||
return f.s, f.err
|
||||
}
|
||||
|
||||
func (f *fakeViewService) LoadMessages(_ context.Context, sessionID string, limit int, _ *time.Time, opts ...sdk.ResourceURLOptions) ([]sdk.Message, error) {
|
||||
f.loadCall.called = true
|
||||
f.loadCall.sessionID = sessionID
|
||||
f.loadCall.limit = limit
|
||||
return f.msgs, f.msgsErr
|
||||
}
|
||||
|
||||
func TestView_Text(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{
|
||||
ID: "s_abc",
|
||||
Title: "Design review",
|
||||
Description: "RAG chunking strategy review",
|
||||
CreatedAt: "2026-05-10T09:00:00Z",
|
||||
UpdatedAt: "2026-05-12T14:00:00Z",
|
||||
}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_abc"))
|
||||
got := out.String()
|
||||
for _, want := range []string{"s_abc", "Design review", "RAG chunking strategy review", "2026-05-12"} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
assert.Equal(t, "s_abc", svc.gotID)
|
||||
}
|
||||
|
||||
func TestView_JSON(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s_abc", Title: "T", UpdatedAt: "2026-05-12T14:00:00Z"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
|
||||
|
||||
body := out.String()
|
||||
var env struct {
|
||||
OK bool `json:"ok"`
|
||||
Data sdk.Session `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal([]byte(body), &env), "expected valid JSON envelope; got %q", body)
|
||||
assert.True(t, env.OK, "envelope.ok must be true")
|
||||
assert.Equal(t, "s_abc", env.Data.ID, "envelope.data.id must be s_abc")
|
||||
}
|
||||
|
||||
func TestView_NotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{err: errors.New("HTTP error 404: not found")}
|
||||
err := runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_missing")
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
func TestView_OmitsEmptyDescription(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s_min", Title: "Bare"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_min"))
|
||||
// Empty Description should not produce an empty `DESC:` line.
|
||||
for line := range strings.SplitSeq(out.String(), "\n") {
|
||||
if strings.HasPrefix(line, "DESC:") {
|
||||
t.Errorf("empty description should be omitted, found %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- --full / --limit tests ---
|
||||
|
||||
func TestView_Full_LoadsMessages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{
|
||||
s: &sdk.Session{ID: "s_abc", Title: "Chat"},
|
||||
msgs: []sdk.Message{
|
||||
{ID: "m1", Role: "user", Content: "What is RAG?", CreatedAt: time.Date(2026, 5, 15, 14, 32, 0, 0, time.UTC)},
|
||||
{ID: "m2", Role: "assistant", Content: "RAG stands for retrieval-augmented generation.", CreatedAt: time.Date(2026, 5, 15, 14, 32, 5, 0, time.UTC)},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_abc"))
|
||||
got := out.String()
|
||||
assert.True(t, svc.loadCall.called, "expected LoadMessages to be called")
|
||||
assert.Equal(t, "s_abc", svc.loadCall.sessionID)
|
||||
assert.Equal(t, 50, svc.loadCall.limit)
|
||||
for _, want := range []string{"Messages (2)", "[user]", "[assistant]", "What is RAG?", "retrieval-augmented generation"} {
|
||||
assert.Contains(t, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestView_Full_NoMessages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{
|
||||
s: &sdk.Session{ID: "s_empty", Title: "Empty"},
|
||||
msgs: []sdk.Message{},
|
||||
}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s_empty"))
|
||||
got := out.String()
|
||||
assert.Contains(t, got, "Messages (0)")
|
||||
}
|
||||
|
||||
func TestView_Full_LimitInvalid_Zero(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
|
||||
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 0}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
|
||||
func TestView_Full_LimitInvalid_TooLarge(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
|
||||
err := runView(context.Background(), &ViewOptions{Full: true, Limit: 1001}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
}
|
||||
|
||||
// --limit without --full is rejected with input.invalid_argument — same
|
||||
// pattern as `--title` requires `--from-url` in `doc upload`.
|
||||
func TestView_LimitWithoutFull(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s"}}
|
||||
err := runView(context.Background(), &ViewOptions{Full: false, Limit: 100, LimitSet: true}, &cmdutil.FormatOptions{Mode: cmdutil.FormatText}, svc, "s")
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "input.invalid_argument")
|
||||
assert.Contains(t, err.Error(), "--limit")
|
||||
assert.Contains(t, err.Error(), "--full")
|
||||
}
|
||||
|
||||
func TestView_Full_JSON_HasMessages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{
|
||||
s: &sdk.Session{ID: "s_abc", Title: "T"},
|
||||
msgs: []sdk.Message{
|
||||
{ID: "m1", Role: "user", Content: "hi"},
|
||||
},
|
||||
}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{Full: true, Limit: 50}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
|
||||
body := out.String()
|
||||
assert.Contains(t, body, `"messages":`)
|
||||
assert.Contains(t, body, `"id":"m1"`)
|
||||
assert.Contains(t, body, `"role":"user"`)
|
||||
}
|
||||
|
||||
// Without --full, the LoadMessages SDK call must not fire and the JSON
|
||||
// payload must not contain a `messages` key.
|
||||
func TestView_NoFull_DoesNotCallLoadMessages(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &fakeViewService{s: &sdk.Session{ID: "s_abc"}}
|
||||
require.NoError(t, runView(context.Background(), &ViewOptions{}, &cmdutil.FormatOptions{Mode: cmdutil.FormatJSON}, svc, "s_abc"))
|
||||
assert.False(t, svc.loadCall.called, "LoadMessages must not be called without --full")
|
||||
assert.NotContains(t, out.String(), `"messages":`)
|
||||
}
|
||||
Reference in New Issue
Block a user