feat(cli)!: v0.10 reliability, agent-UX, and command-surface hardening
A hardening + finalization pass over the agent-first CLI: correctness fixes,
richer machine-readable signals, flag/naming consistency, and a symmetric
config surface. Pre-1.0, so it includes breaking renames.
Correctness:
- agent update: resolve/validate --model/--rerank-model (was storing a bogus
name verbatim, corrupting config.model_id).
- doctor: honor WEKNORA_HOST / WEKNORA_API_KEY (headless path no longer reports
"no host configured").
- session ask / MCP session_ask: text answer was empty on non-TTY — the agent
stream sets Done=true on an intermediate agent_query frame before the answer,
and AgentAccumulator treated the first Done as terminal. Terminate on the
`complete` event (new sdk.AgentResponseTypeComplete), not a per-frame Done.
- batch exit codes: any per-item failure collapses to operation.failed (exit 1),
including `doc upload --recursive` partial failures — a permanent per-file
failure (e.g. a duplicate) no longer surfaces as a retryable exit 7 an agent
would loop on; per-item typed errors stay in the envelope.
Agent-first signals & discovery:
- error.exit_code in the envelope (type + exit_code disambiguate the
input.invalid_argument exit-2-vs-5 split in one JSON read).
- meta.hint on empty content search and on draft doc create; doc wait fails fast
on a never-parsing draft instead of hanging to --timeout.
- retrieval-readiness is visible in the natural flow: kb status / kb check emit
retrieval_ready, and kb create hints the fix when no embedding model is bound
— an unconfigured KB no longer looks silently healthy.
- schema contract completeness: every leaf declares output + >=1 example
(drift-guarded); output strings match the meta actually emitted; chunk list
and search docs now emit meta.total_count (both previously dropped it).
- schema tolerates a quoted multi-word command label; zero-state auth — and
`link` with no profile — point at profile setup / the headless WEKNORA_KB_ID
path instead of looping on `auth login`.
- id-addressed reads tolerate a redundant --kb (doc view/wait, chunk list/view
accept and ignore it, declared in schema) so a carried-over --kb doesn't
exit 2; streaming commands warn that --jq does not apply to an NDJSON stream.
- keep JSON-always as the default; --jq hints spell out the .data path.
Consistency & gating:
- doc create: drop the deprecated --name alias (--title only; pre-1.0 break).
- chunk list --limit aligned to 1..10000; model list --limit/-L with
has_more/total_count; api write-gates -X PUT/PATCH (exit 10); skills install
expands a leading ~.
- docs corrected: search docs / doc list --keyword help is case-insensitive
(server does LOWER LIKE); AGENTS.md risk-action list (no phantom kb.init; add
model.update / kb.config.set) and batch example (failed item carries `error`);
session resume --message id comes from `message list`, not the stream.
- auth/profile ergonomics: env credentials are now first-class — `auth token`
prints the active WEKNORA_API_KEY / WEKNORA_TOKEN, and auth login/logout/refresh
give an env-aware message instead of looping on "run auth login". `auth logout`
clears credentials but keeps the profile registered (host preserved for
re-login); deleting a profile is `profile remove`'s job (clean logout/remove
separation, matching gh / lark).
Config surface (symmetric read/write, in-place model edits):
- kb config now returns a secret-free KBModelConfigView (was {}); `kb config`
reads, new `kb config set` writes; `kb init` removed (misnomer).
- kb create --chat-model: retrieval-ready in one step.
- model update: edit a model in place (id preserved, references survive) —
rotate --api-key-stdin, change base-url / display-name / etc.
- session continue-stream renamed to session resume.
Docs: AGENTS.md is the single wire-contract source; CHANGELOG slimmed; stale
kb-init / continue-stream references removed; skill wire-vocab guard extended.
AGENTS.md / weknora-shared SKILL document retrieval_ready (a KB needs an
embedding model to be searchable), that --jq does not apply to NDJSON
streams, and the env-credential-first auth path; the KB quickstart example
now creates a retrieval-ready KB.
This commit is contained in:
@@ -61,7 +61,7 @@ the caller to thread follow-ups.
|
||||
|
||||
AI agents: this is the primary entrypoint for invoking custom agents.
|
||||
The 'weknora agent' subtree handles CRUD only (list / view / create /
|
||||
edit / delete / status / check).
|
||||
update / delete / status / check).
|
||||
|
||||
Modes:
|
||||
--format json (default): one JSON envelope with answer events
|
||||
|
||||
@@ -47,6 +47,9 @@ func (s *scriptedAskSvc) AgentQAStreamWithRequest(_ context.Context, sessionID s
|
||||
func answerEvent(content string) *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeAnswer, Content: content}
|
||||
}
|
||||
// doneEvent is the stream's terminal frame. The real server ends an agent
|
||||
// stream with a `complete` event (it also sets Done=true on intermediate
|
||||
// frames), so the terminal is modeled as complete, not a bare answer+done.
|
||||
func doneEvent() *sdk.AgentStreamResponse {
|
||||
return &sdk.AgentStreamResponse{ResponseType: sdk.AgentResponseTypeComplete, Done: true}
|
||||
}
|
||||
|
||||
@@ -78,7 +78,7 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "List chat sessions for the active profile. Results come with meta.count; use --limit to cap, --all-pages to walk every server page, --since to filter by recency (e.g. 7d).",
|
||||
Examples: []string{"weknora session list --format json", "weknora session list --all-pages --since 7d --format json"},
|
||||
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the total returned; meta.total_count is the server-side total before --since filtering",
|
||||
Output: "envelope.data is an array of Session objects with id, title, updated_at; meta.count is the returned count; meta.total_count is the server-side total before --since filtering; meta.has_more=true when --limit truncated",
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// continue_stream.go implements `weknora session continue-stream` —
|
||||
// resume.go implements `weknora session resume` —
|
||||
// re-attach to an SSE event buffer for an in-progress or already-completed
|
||||
// assistant message under a known session_id.
|
||||
//
|
||||
@@ -23,44 +23,42 @@ package sessioncmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
|
||||
"github.com/Tencent/WeKnora/cli/internal/cmdutil"
|
||||
"github.com/Tencent/WeKnora/cli/internal/iostreams"
|
||||
"github.com/Tencent/WeKnora/cli/internal/output"
|
||||
"github.com/Tencent/WeKnora/cli/internal/sse"
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// continueStreamFields enumerates the NDJSON init-event + raw SDK event
|
||||
// resumeFields enumerates the NDJSON init-event + raw SDK event
|
||||
// vocabulary surfaced for `--format json` / `--format ndjson` discovery.
|
||||
var continueStreamFields = []string{
|
||||
var resumeFields = []string{
|
||||
"session_id", "message_id",
|
||||
// SDK StreamResponse fields (pass-through): id, response_type, content,
|
||||
// done, knowledge_references, assistant_message_id, session_id,
|
||||
// tool_calls, data
|
||||
}
|
||||
|
||||
// ContinueStreamOptions captures `session continue-stream` flag/arg state.
|
||||
type ContinueStreamOptions struct {
|
||||
// ResumeOptions captures `session resume` flag/arg state.
|
||||
type ResumeOptions struct {
|
||||
SessionID string
|
||||
MessageID string
|
||||
}
|
||||
|
||||
// ContinueStreamService is the narrow SDK surface this command depends on.
|
||||
// ResumeService is the narrow SDK surface this command depends on.
|
||||
// *sdk.Client satisfies it; tests substitute a fake. Compile-time check
|
||||
// at the bottom of this file.
|
||||
type ContinueStreamService interface {
|
||||
type ResumeService interface {
|
||||
ContinueStream(ctx context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error
|
||||
}
|
||||
|
||||
// NewCmdContinueStream builds `weknora session continue-stream <session-id> --message <id>`.
|
||||
func NewCmdContinueStream(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ContinueStreamOptions{}
|
||||
// NewCmdResume builds `weknora session resume <session-id> --message <id>`.
|
||||
func NewCmdResume(f *cmdutil.Factory) *cobra.Command {
|
||||
opts := &ResumeOptions{}
|
||||
cmd := &cobra.Command{
|
||||
Use: "continue-stream <session-id>",
|
||||
Use: "resume <session-id>",
|
||||
Short: "Resume an SSE event stream for an in-progress or completed session message",
|
||||
Long: `Re-attach to the SSE event buffer for an assistant message under a known session.
|
||||
|
||||
@@ -92,8 +90,8 @@ regardless of --format value. The operator use case (incident response,
|
||||
debugging) always wants the raw event log; there is no human-text rendering.
|
||||
--format json and --format ndjson behave identically here; --format text is
|
||||
silently treated as NDJSON.`,
|
||||
Example: ` weknora session continue-stream sess_xyz --message msg_abc
|
||||
weknora session continue-stream sess_xyz -m msg_abc --format ndjson`,
|
||||
Example: ` weknora session resume sess_xyz --message msg_abc
|
||||
weknora session resume sess_xyz -m msg_abc --format ndjson`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.SessionID = args[0]
|
||||
@@ -106,38 +104,39 @@ silently treated as NDJSON.`,
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return runContinueStream(c.Context(), opts, fopts, cli)
|
||||
return runResume(c.Context(), opts, fopts, cli)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.MessageID, "message", "m", "",
|
||||
"Assistant message ID to resume (from the init or agent_query event of the original stream)")
|
||||
_ = cmd.MarkFlagRequired("message")
|
||||
cmdutil.AddFormatFlag(cmd, continueStreamFields...)
|
||||
cmdutil.AddFormatFlag(cmd, resumeFields...)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Resume an SSE event stream for an in-progress or completed assistant message. Produces an NDJSON event stream: init line (session_id, message_id) then raw SDK StreamResponse events.",
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from prior init / agent_query event)"},
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (persisted assistant message id — get it from `weknora message list --session <id>`; a live stream's assistant_message_id is not resumable once the message persists)"},
|
||||
Examples: []string{
|
||||
"weknora session continue-stream sess_xyz --message msg_abc --format json",
|
||||
"# Network-blip recovery: replay with same session_id + message_id from the original 'session ask' init event",
|
||||
"weknora session resume sess_xyz --message msg_abc --format json",
|
||||
"# Get the message id from: weknora message list --session <session-id> (the persisted assistant message)",
|
||||
},
|
||||
Output: "NDJSON stream: {type:init, session_id, message_id, profile} then SDK StreamResponse events (response_type, content, done, knowledge_references, assistant_message_id, ...)",
|
||||
Warnings: []string{
|
||||
"Server replays from event 0 (NOT cursor-from-disconnect). Agents that already consumed events on the original stream MUST dedupe by message_id + event hash to avoid double-processing.",
|
||||
"Buffer TTL: redis mode 1h hardcoded; memory mode = process lifetime. After expiry the CLI returns local.sse_stream_aborted.",
|
||||
"Output is always NDJSON (an event stream, not an envelope): --jq does not apply and --format text/json/ndjson behave identically here — parse the event lines yourself.",
|
||||
},
|
||||
})
|
||||
return cmd
|
||||
}
|
||||
|
||||
// runContinueStream is the testable core: validate, dispatch the resume, and
|
||||
// runResume is the testable core: validate, dispatch the resume, and
|
||||
// route the NDJSON stream. Returns a typed error.
|
||||
//
|
||||
// Always emits NDJSON: a buffered envelope makes no sense for a streaming
|
||||
// command, and continue-stream has no human-text use case (operators reach
|
||||
// command, and resume has no human-text use case (operators reach
|
||||
// for it during incident response / debugging, which always wants the raw
|
||||
// event log). --format text is therefore treated identically to --format
|
||||
// json/ndjson here.
|
||||
func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdutil.FormatOptions, svc ContinueStreamService) error {
|
||||
func runResume(ctx context.Context, opts *ResumeOptions, _ *cmdutil.FormatOptions, svc ResumeService) error {
|
||||
if opts.SessionID == "" {
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "session-id argument cannot be empty")
|
||||
}
|
||||
@@ -145,7 +144,7 @@ func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdu
|
||||
return cmdutil.NewError(cmdutil.CodeInputInvalidArgument, "--message cannot be empty")
|
||||
}
|
||||
if svc == nil {
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session continue-stream: no SDK client available")
|
||||
return cmdutil.NewError(cmdutil.CodeServerError, "session resume: no SDK client available")
|
||||
}
|
||||
|
||||
w := iostreams.IO.Out
|
||||
@@ -165,52 +164,27 @@ func runContinueStream(ctx context.Context, opts *ContinueStreamOptions, _ *cmdu
|
||||
// 2. Open the SDK replay stream and pass each event through as a bare
|
||||
// NDJSON line. The SDK's StreamResponse is the source of truth for
|
||||
// the event vocabulary; the CLI does not reshape it.
|
||||
var streamErrMsg string
|
||||
// The SDK invokes the callback for each event (including a terminal
|
||||
// response_type=error frame) BEFORE returning, so raw passthrough still
|
||||
// emits every event; on a terminal error frame the SDK then returns an
|
||||
// *SSEStreamError. No CLI-level early-terminate is needed.
|
||||
cb := func(r *sdk.StreamResponse) error {
|
||||
isErr := r != nil && r.ResponseType == sdk.ResponseTypeError
|
||||
if isErr && streamErrMsg == "" {
|
||||
if r.Content != "" {
|
||||
streamErrMsg = r.Content
|
||||
} else if r.Data != nil {
|
||||
if e, ok := r.Data["error"].(string); ok {
|
||||
streamErrMsg = e
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := output.EmitSDKEvent(w, r); err != nil {
|
||||
return err
|
||||
}
|
||||
// Stop once the error frame is emitted rather than blocking on the SDK
|
||||
// read until the ~30s transport timeout (the server holds the stream
|
||||
// open after the error). streamErrMsg carries the real reason.
|
||||
if isErr {
|
||||
return sse.ErrTerminate
|
||||
}
|
||||
return nil
|
||||
return output.EmitSDKEvent(w, r)
|
||||
}
|
||||
err := svc.ContinueStream(ctx, opts.SessionID, opts.MessageID, cb)
|
||||
if errors.Is(err, sse.ErrTerminate) {
|
||||
err = nil
|
||||
}
|
||||
// A server-delivered error frame is the authoritative failure reason —
|
||||
// surface it as operation.failed (exit 1) instead of the transport timeout
|
||||
// the server's stream-close triggers afterwards. Mirrors chat / session ask.
|
||||
if streamErrMsg != "" {
|
||||
return cmdutil.NewError(cmdutil.CodeOperationFailed, "continue stream failed: "+streamErrMsg)
|
||||
}
|
||||
if err != nil {
|
||||
// Ctrl-C / SIGTERM lineage (operator gave up on the resume).
|
||||
if cmdutil.IsCancelled(ctx, err) {
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session continue-stream cancelled")
|
||||
return cmdutil.Wrapf(cmdutil.CodeOperationCancelled, err, "session resume cancelled")
|
||||
}
|
||||
// Pre-stream HTTP / transport failure (e.g. 404 if message_id is
|
||||
// unknown, or buffer-expired body from the server). Route through
|
||||
// the canonical classifier so codes survive — 404 still surfaces
|
||||
// as resource.not_found etc.
|
||||
return cmdutil.WrapHTTP(err, "continue stream")
|
||||
// WrapStream routes through ClassifySDKError: a terminal SSE error
|
||||
// frame classifies as server.error (matching chat / session ask); a
|
||||
// pre-stream HTTP failure (e.g. 404 for an unknown message_id) still
|
||||
// surfaces via ClassifyHTTPError as resource.not_found etc.
|
||||
return cmdutil.WrapStream(err, "resume stream")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// compile-time check: production SDK client satisfies ContinueStreamService.
|
||||
var _ ContinueStreamService = (*sdk.Client)(nil)
|
||||
// compile-time check: production SDK client satisfies ResumeService.
|
||||
var _ ResumeService = (*sdk.Client)(nil)
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
sdk "github.com/Tencent/WeKnora/client"
|
||||
)
|
||||
|
||||
// scriptedContinueStreamSvc serves a canned stream of StreamResponse events
|
||||
// to runContinueStream and records the (sessionID, messageID) passed in.
|
||||
type scriptedContinueStreamSvc struct {
|
||||
// scriptedResumeSvc serves a canned stream of StreamResponse events
|
||||
// to runResume and records the (sessionID, messageID) passed in.
|
||||
type scriptedResumeSvc struct {
|
||||
events []*sdk.StreamResponse
|
||||
streamErr error
|
||||
got struct {
|
||||
@@ -27,7 +27,7 @@ type scriptedContinueStreamSvc struct {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *scriptedContinueStreamSvc) ContinueStream(_ context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error {
|
||||
func (s *scriptedResumeSvc) ContinueStream(_ context.Context, sessionID, messageID string, cb func(*sdk.StreamResponse) error) error {
|
||||
s.got.sessionID = sessionID
|
||||
s.got.messageID = messageID
|
||||
for _, e := range s.events {
|
||||
@@ -51,11 +51,11 @@ func contStreamComplete() *sdk.StreamResponse {
|
||||
// arrives.
|
||||
func TestContinueStream_NDJSON_FirstLineIsInitWithMessageID(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{contStreamAnswer("hello"), contStreamComplete()},
|
||||
}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_xyz", MessageID: "msg_abc"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
opts := &ResumeOptions{SessionID: "sess_xyz", MessageID: "msg_abc"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
require.GreaterOrEqual(t, len(lines), 1, "expected at least the init line")
|
||||
@@ -75,15 +75,15 @@ func TestContinueStream_NDJSON_FirstLineIsInitWithMessageID(t *testing.T) {
|
||||
// events = N+1 total lines, all valid JSON.
|
||||
func TestContinueStream_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
out, _ := iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{
|
||||
svc := &scriptedResumeSvc{
|
||||
events: []*sdk.StreamResponse{
|
||||
contStreamAnswer("alpha"),
|
||||
contStreamAnswer("beta"),
|
||||
contStreamComplete(),
|
||||
},
|
||||
}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_y"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_y"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
|
||||
lines := strings.Split(strings.TrimRight(out.String(), "\n"), "\n")
|
||||
// 1 init + 3 SDK events = 4 lines.
|
||||
@@ -98,9 +98,9 @@ func TestContinueStream_NDJSON_PassthroughEvents(t *testing.T) {
|
||||
// flow through to the SDK call.
|
||||
func TestContinueStream_PassesSessionAndMessageIDToSDK(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{events: []*sdk.StreamResponse{contStreamComplete()}}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_42", MessageID: "msg_99"}
|
||||
require.NoError(t, runContinueStream(context.Background(), opts, ndjsonOpts(), svc))
|
||||
svc := &scriptedResumeSvc{events: []*sdk.StreamResponse{contStreamComplete()}}
|
||||
opts := &ResumeOptions{SessionID: "sess_42", MessageID: "msg_99"}
|
||||
require.NoError(t, runResume(context.Background(), opts, ndjsonOpts(), svc))
|
||||
assert.Equal(t, "sess_42", svc.got.sessionID)
|
||||
assert.Equal(t, "msg_99", svc.got.messageID)
|
||||
}
|
||||
@@ -110,9 +110,9 @@ func TestContinueStream_PassesSessionAndMessageIDToSDK(t *testing.T) {
|
||||
// core must also refuse empty strings).
|
||||
func TestContinueStream_EmptySessionID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{}
|
||||
opts := &ContinueStreamOptions{SessionID: "", MessageID: "msg_x"}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{}
|
||||
opts := &ResumeOptions{SessionID: "", MessageID: "msg_x"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -123,9 +123,9 @@ func TestContinueStream_EmptySessionID_Rejected(t *testing.T) {
|
||||
// point.
|
||||
func TestContinueStream_EmptyMessageID_Rejected(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: ""}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: ""}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -138,9 +138,9 @@ func TestContinueStream_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
svc := &scriptedContinueStreamSvc{streamErr: context.Canceled}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runContinueStream(ctx, opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{streamErr: context.Canceled}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runResume(ctx, opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
@@ -152,20 +152,36 @@ func TestContinueStream_Cancellation_MapsToOperationCancelled(t *testing.T) {
|
||||
// the canonical HTTP classifier.
|
||||
func TestContinueStream_NotFound_MapsToResourceNotFound(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedContinueStreamSvc{streamErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &ContinueStreamOptions{SessionID: "sess_x", MessageID: "msg_missing"}
|
||||
err := runContinueStream(context.Background(), opts, ndjsonOpts(), svc)
|
||||
svc := &scriptedResumeSvc{streamErr: errors.New("HTTP error 404: not found")}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_missing"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeResourceNotFound, typed.Code)
|
||||
}
|
||||
|
||||
// TestResume_TerminalStreamError_MapsToServerError pins that a terminal SSE
|
||||
// error frame (surfaced by the SDK as *SSEStreamError) classifies as
|
||||
// server.error (exit 7) — the SAME as chat / session ask. Guards against the
|
||||
// prior inconsistency where resume reported the identical server condition as
|
||||
// exit 1 while chat/ask reported exit 7.
|
||||
func TestResume_TerminalStreamError_MapsToServerError(t *testing.T) {
|
||||
_, _ = iostreams.SetForTest(t)
|
||||
svc := &scriptedResumeSvc{streamErr: sdk.NewSSEStreamError("no chat model configured")}
|
||||
opts := &ResumeOptions{SessionID: "sess_x", MessageID: "msg_x"}
|
||||
err := runResume(context.Background(), opts, ndjsonOpts(), svc)
|
||||
require.Error(t, err)
|
||||
var typed *cmdutil.Error
|
||||
require.ErrorAs(t, err, &typed)
|
||||
assert.Equal(t, cmdutil.CodeServerError, typed.Code)
|
||||
}
|
||||
|
||||
// TestContinueStream_RequiresMessageFlag verifies cobra refuses to run the
|
||||
// command without --message (the flag is marked required).
|
||||
func TestContinueStream_RequiresMessageFlag(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdContinueStream(f)
|
||||
cmd := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -185,7 +201,7 @@ func TestContinueStream_RequiresMessageFlag(t *testing.T) {
|
||||
// command without the positional <session-id>.
|
||||
func TestContinueStream_RequiresSessionIDArg(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
cmd := NewCmdContinueStream(f)
|
||||
cmd := NewCmdResume(f)
|
||||
var buf bytes.Buffer
|
||||
cmd.SetOut(&buf)
|
||||
cmd.SetErr(&buf)
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package sessioncmd holds `weknora session` command tree (list / view /
|
||||
// delete / ask / continue-stream / stop) for chat history and agent invocation.
|
||||
// delete / ask / resume / stop) for chat history and agent invocation.
|
||||
//
|
||||
// Package name `sessioncmd` (not `session`) so callers can `import sdk
|
||||
// "github.com/Tencent/WeKnora/client"` and use `sdk.Session` without
|
||||
@@ -22,7 +22,7 @@ func NewCmd(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd.AddCommand(NewCmdView(f))
|
||||
cmd.AddCommand(NewCmdDelete(f))
|
||||
cmd.AddCommand(NewCmdAsk(f))
|
||||
cmd.AddCommand(NewCmdContinueStream(f))
|
||||
cmd.AddCommand(NewCmdResume(f))
|
||||
cmd.AddCommand(NewCmdStop(f))
|
||||
cmd.AddCommand(NewCmdToolApproval(f))
|
||||
return cmd
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
// Unlike Ctrl-C (which only drops the local connection while the server keeps
|
||||
// generating and billing tokens), this tells the server to stop.
|
||||
//
|
||||
// This is the symmetric counterpart to `session continue-stream`: both key on
|
||||
// This is the symmetric counterpart to `session resume`: both key on
|
||||
// (session_id, message_id). The message_id comes from the init event of the
|
||||
// original chat / session ask / continue-stream stream.
|
||||
// original chat / session ask / resume stream.
|
||||
package sessioncmd
|
||||
|
||||
import (
|
||||
@@ -52,7 +52,7 @@ func NewCmdStop(f *cmdutil.Factory) *cobra.Command {
|
||||
session. Unlike Ctrl-C (which only drops the local connection while the server
|
||||
keeps generating and billing tokens), this tells the server to stop.
|
||||
|
||||
Symmetric with 'session continue-stream': both key on (session_id, message_id).`,
|
||||
Symmetric with 'session resume': both key on (session_id, message_id).`,
|
||||
Example: ` weknora session stop sess_xyz --message msg_abc`,
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
@@ -81,7 +81,7 @@ Symmetric with 'session continue-stream': both key on (session_id, message_id).`
|
||||
cmdutil.AddFormatFlag(cmd, stopFields...)
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "Stop server-side generation for an in-flight assistant message (counterpart to continue-stream). The message_id comes from the init event of the chat / session ask / continue-stream stream you're stopping.",
|
||||
UsedFor: "Stop server-side generation for an in-flight assistant message (counterpart to resume). The message_id comes from the init event of the chat / session ask / resume stream you're stopping.",
|
||||
RequiredFlags: []string{"<session-id> (positional)", "--message (message_id from the init event of the stream you're stopping)"},
|
||||
Examples: []string{"weknora session stop sess_xyz --message msg_abc"},
|
||||
Output: "envelope {session_id, message_id, stopped:true}",
|
||||
|
||||
@@ -49,7 +49,7 @@ When a server-side agent run (weknora session ask) needs to call a tool
|
||||
that requires approval, the stream emits a tool-approval event carrying a
|
||||
pending id and the run blocks. This command unblocks it: approve (default)
|
||||
lets the tool call execute, --reject cancels it. After resolving, resume
|
||||
the answer with weknora session continue-stream.
|
||||
the answer with weknora session resume.
|
||||
|
||||
--modified-args replaces the tool call arguments on approve (JSON object).
|
||||
It conflicts with --reject (rejected calls never execute).
|
||||
@@ -122,7 +122,7 @@ func newCmdResolve(f *cmdutil.Factory) *cobra.Command {
|
||||
cmdutil.AddDryRunFlag(cmd, &opts.DryRun)
|
||||
cmdutil.SetRisk(cmd, "session.tool_approval.resolve")
|
||||
cmdutil.SetAgentHelp(cmd, cmdutil.AgentHelp{
|
||||
UsedFor: "approve or reject a pending tool call from an agent run; then resume with session continue-stream",
|
||||
UsedFor: "approve or reject a pending tool call from an agent run; then resume with session resume",
|
||||
RequiredFlags: []string{"<pending-id> (positional)"},
|
||||
Examples: []string{
|
||||
"weknora session tool-approval resolve pend_abc -y",
|
||||
|
||||
Reference in New Issue
Block a user