From d44015d4e43d4f03ade94a0525f8271dd11cee26 Mon Sep 17 00:00:00 2001 From: Wenxi Date: Mon, 11 May 2026 18:44:14 -0700 Subject: [PATCH] feat(onyx-cli): agentic search command (#11001) --- .../tests/cli/test_cli_commands.py | 197 ++++++++++++++++ cli/cmd/root.go | 1 + cli/cmd/search.go | 160 +++++++++++++ cli/cmd/search_test.go | 214 ++++++++++++++++++ cli/internal/api/client.go | 22 +- cli/internal/api/client_test.go | 52 +++++ cli/internal/embedded/SKILL.md | 77 +++++-- cli/internal/exitcodes/codes.go | 2 +- cli/internal/exitcodes/codes_test.go | 1 + cli/internal/models/models.go | 31 +++ .../features/search/3-cli-search-command.md | 2 +- 11 files changed, 735 insertions(+), 24 deletions(-) create mode 100644 cli/cmd/search.go create mode 100644 cli/cmd/search_test.go diff --git a/backend/tests/integration/tests/cli/test_cli_commands.py b/backend/tests/integration/tests/cli/test_cli_commands.py index a668305b4e..7c50fcf7e0 100644 --- a/backend/tests/integration/tests/cli/test_cli_commands.py +++ b/backend/tests/integration/tests/cli/test_cli_commands.py @@ -31,6 +31,14 @@ Test Suite: 15. test_help_non_tty - No subcommand prints help, exits 0 16. test_version_flag - Prints client and server version 17. test_experiments - Lists feature flags +18. test_search_returns_results - Search returns seeded document content +19. test_search_raw - --raw outputs full SearchAPIResponse as JSON +20. test_search_truncation - --max-output truncates with temp file path +21. test_search_no_query - No query returns exit code 2 +22. test_search_bad_pat - Invalid PAT returns exit code 4 +23. test_search_not_configured - Missing PAT returns exit code 3 +24. test_search_source_filter - --source filters results to matching source types +25. test_search_agent_id - --agent-id scopes search to a persona's document sets """ import json @@ -41,9 +49,14 @@ from pathlib import Path import pytest +from onyx.configs.constants import DocumentSource from tests.integration.common_utils.constants import API_SERVER_URL +from tests.integration.common_utils.managers.cc_pair import CCPairManager +from tests.integration.common_utils.managers.document import DocumentManager +from tests.integration.common_utils.managers.document_set import DocumentSetManager from tests.integration.common_utils.managers.pat import PATManager from tests.integration.common_utils.managers.persona import PersonaManager +from tests.integration.common_utils.test_models import DATestAPIKey from tests.integration.common_utils.test_models import DATestLLMProvider from tests.integration.common_utils.test_models import DATestPersona from tests.integration.common_utils.test_models import DATestUser @@ -373,3 +386,187 @@ def test_experiments(cli_binary: Path) -> None: assert result.returncode == 0 assert "Stream Markdown" in result.stdout + + +# --- search --- + + +def test_search_returns_results( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 + api_key: DATestAPIKey, +) -> None: + """Search returns results containing the seeded document content.""" + cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user) + phrase = "cli-search-unique-phrase-alpha" + DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key) + + result = run_cli(cli_binary, ["search", phrase], pat=pat_token, timeout=120) + + assert result.returncode == 0, f"stderr: {result.stderr}" + assert len(result.stdout.strip()) > 0 + assert phrase in result.stdout + + +def test_search_raw( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 + api_key: DATestAPIKey, +) -> None: + """--raw outputs the full SearchAPIResponse as JSON.""" + cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user) + phrase = "cli-search-raw-unique-phrase" + doc = DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key) + + result = run_cli( + cli_binary, ["search", "--raw", phrase], pat=pat_token, timeout=120 + ) + + assert result.returncode == 0, f"stderr: {result.stderr}" + + data = json.loads(result.stdout) + assert isinstance(data["results"], list) + assert len(data["results"]) > 0 + assert isinstance(data["llm_facing_text"], str) + assert len(data["llm_facing_text"]) > 0 + assert isinstance(data["citation_mapping"], dict) + + result_doc_ids = {r["document_id"] for r in data["results"]} + assert doc.id in result_doc_ids + + +def test_search_truncation( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 + api_key: DATestAPIKey, +) -> None: + """--max-output truncates output and shows temp file path.""" + cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user) + phrase = "cli-search-truncation-unique" + DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key) + + result = run_cli( + cli_binary, + ["search", "--max-output", "50", phrase], + pat=pat_token, + timeout=120, + ) + + assert result.returncode == 0, f"stderr: {result.stderr}" + assert "response truncated" in result.stdout + assert "Full response:" in result.stdout + + +def test_search_no_query( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, # noqa: ARG001 +) -> None: + """Search with no query returns BadRequest (exit code 2).""" + result = run_cli(cli_binary, ["search"], pat=pat_token) + + assert result.returncode == 2 + + +def test_search_bad_pat( + cli_binary: Path, + admin_user: DATestUser, # noqa: ARG001 +) -> None: + """Search with an invalid PAT returns AuthFailure (exit code 4).""" + result = run_cli(cli_binary, ["search", "test"], pat="bad-token") + + assert result.returncode == 4 + + +def test_search_not_configured( + cli_binary: Path, + admin_user: DATestUser, # noqa: ARG001 +) -> None: + """Search without PAT returns NotConfigured (exit code 3).""" + result = run_cli(cli_binary, ["search", "test"]) + + assert result.returncode == 3 + + +def test_search_source_filter( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 + api_key: DATestAPIKey, +) -> None: + """--source filters results to matching source types.""" + cc_pair = CCPairManager.create_from_scratch(user_performing_action=admin_user) + phrase = "cli-search-source-filter-unique" + DocumentManager.seed_doc_with_content(cc_pair, phrase, api_key) + + # TODO(@wenxi-onyx): Make the integration test manager allow source types during seeding + result = run_cli( + cli_binary, + ["search", "--raw", "--source", DocumentSource.NOT_APPLICABLE.value, phrase], + pat=pat_token, + timeout=120, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + data = json.loads(result.stdout) + assert len(data["results"]) > 0 + # All results should match the requested source + for r in data["results"]: + assert r["source_type"] == DocumentSource.NOT_APPLICABLE.value + + +def test_search_agent_id( + cli_binary: Path, + pat_token: str, + admin_user: DATestUser, + llm_provider: DATestLLMProvider, # noqa: ARG001 + api_key: DATestAPIKey, +) -> None: + """--agent-id scopes search to a persona's document sets.""" + cc_pair_in = CCPairManager.create_from_scratch(user_performing_action=admin_user) + cc_pair_out = CCPairManager.create_from_scratch(user_performing_action=admin_user) + + shared_phrase = "cli-search-agent-scope-unique" + doc_in = DocumentManager.seed_doc_with_content( + cc_pair_in, + f"{shared_phrase} in scope", + api_key, + ) + doc_out = DocumentManager.seed_doc_with_content( + cc_pair_out, + f"{shared_phrase} out of scope", + api_key, + ) + + doc_set = DocumentSetManager.create( + cc_pair_ids=[cc_pair_in.id], + user_performing_action=admin_user, + ) + DocumentSetManager.wait_for_sync( + user_performing_action=admin_user, + document_sets_to_check=[doc_set], + ) + + persona = PersonaManager.create( + user_performing_action=admin_user, + document_set_ids=[doc_set.id], + is_public=True, + ) + + result = run_cli( + cli_binary, + ["search", "--raw", "--agent-id", str(persona.id), shared_phrase], + pat=pat_token, + timeout=120, + ) + assert result.returncode == 0, f"stderr: {result.stderr}" + data = json.loads(result.stdout) + result_doc_ids = {r["document_id"] for r in data["results"]} + assert doc_in.id in result_doc_ids + assert doc_out.id not in result_doc_ids diff --git a/cli/cmd/root.go b/cli/cmd/root.go index 0b426cdcfb..c93107fc99 100644 --- a/cli/cmd/root.go +++ b/cli/cmd/root.go @@ -96,6 +96,7 @@ func Execute() error { chatCmd := newChatCmd() rootCmd.AddCommand(chatCmd) rootCmd.AddCommand(newAskCmd(ios)) + rootCmd.AddCommand(newSearchCmd(ios)) rootCmd.AddCommand(newAgentsCmd(ios)) rootCmd.AddCommand(newConfigureCmd(ios)) rootCmd.AddCommand(newValidateConfigCmd(ios)) diff --git a/cli/cmd/search.go b/cli/cmd/search.go new file mode 100644 index 0000000000..f33711d219 --- /dev/null +++ b/cli/cmd/search.go @@ -0,0 +1,160 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "os" + "os/signal" + "strings" + "syscall" + + "github.com/onyx-dot-app/onyx/cli/internal/exitcodes" + "github.com/onyx-dot-app/onyx/cli/internal/iostreams" + "github.com/onyx-dot-app/onyx/cli/internal/models" + "github.com/onyx-dot-app/onyx/cli/internal/overflow" + "github.com/spf13/cobra" +) + +// buildSearchRequest maps CLI flags into a SearchRequest. +// Boolean "set" parameters indicate whether the corresponding flag was explicitly provided. +func buildSearchRequest( + query string, + sources []string, + days int, daysSet bool, + limit int, limitSet bool, + agentID int, agentIDSet bool, + defaultAgentID int, + noQueryExpansion bool, +) models.SearchRequest { + req := models.SearchRequest{Query: query} + + for _, s := range sources { + s = strings.TrimSpace(s) + if s != "" { + req.Sources = append(req.Sources, s) + } + } + if daysSet { + req.TimeCutoffDays = &days + } + if limitSet { + req.NumResults = limit + } + if agentIDSet { + req.PersonaID = &agentID + } else if defaultAgentID != 0 { + req.PersonaID = &defaultAgentID + } + if noQueryExpansion { + req.SkipQueryExpansion = true + } + return req +} + +func newSearchCmd(ios *iostreams.IOStreams) *cobra.Command { + var ( + searchSources string + searchDays int + searchLimit int + searchAgentID int + searchRaw bool + searchNoQueryExpansion bool + maxOutput int + ) + + cmd := &cobra.Command{ + Use: "search [query]", + Short: "Search company knowledge and return ranked documents", + Long: `Search the Onyx knowledge base and return ranked, cited documents. + +Results are retrieved using the full search pipeline: LLM query expansion, +hybrid retrieval, document selection, and context expansion — the same +search quality as the Onyx chat interface. + +By default, output is the LLM-facing JSON that SearchTool produces — a +{"results": [...]} object with citation IDs, titles, content, and source +types. Use --raw for the full API response including document IDs, scores, +links, and citation mapping. + +When stdout is not a TTY, output is truncated to --max-output bytes and the +full response is saved to a temp file.`, + Args: cobra.MaximumNArgs(1), + Example: ` onyx-cli search "What is our deployment process?" + onyx-cli search --source slack "auth migration status" + onyx-cli search --days 30 --limit 5 "recent production incidents" + onyx-cli search --agent-id 5 "engineering roadmap" + onyx-cli search --raw "API documentation" | jq '.results[].title' + onyx-cli search --no-query-expansion "exact error message text"`, + RunE: func(cmd *cobra.Command, args []string) error { + cfg, client, err := requireClient() + if err != nil { + return err + } + + if len(args) == 0 { + return exitcodes.New(exitcodes.BadRequest, + "no query provided\n Usage: onyx-cli search \"your query\"") + } + + var sources []string + if cmd.Flags().Changed("source") { + sources = strings.Split(searchSources, ",") + } + req := buildSearchRequest( + args[0], + sources, + searchDays, cmd.Flags().Changed("days"), + searchLimit, cmd.Flags().Changed("limit"), + searchAgentID, cmd.Flags().Changed("agent-id"), + cfg.DefaultAgentID, + searchNoQueryExpansion, + ) + + ctx, stop := signal.NotifyContext(cmd.Context(), os.Interrupt, syscall.SIGTERM) + defer stop() + + isTTY := ios.IsStdoutTTY + if isTTY { + fmt.Fprintf(ios.ErrOut, "\033[2mSearching...\033[0m\n") + } + + resp, err := client.Search(ctx, req) + if err != nil { + return apiErrorToExit(err, "search failed") + } + + if searchRaw { + data, err := json.MarshalIndent(resp, "", " ") + if err != nil { + return fmt.Errorf("failed to marshal response: %w", err) + } + fmt.Fprintln(ios.Out, string(data)) + return nil + } + + truncateAt := 0 + if cmd.Flags().Changed("max-output") { + truncateAt = maxOutput + } else if !isTTY { + truncateAt = defaultMaxOutputBytes + } + + ow := &overflow.Writer{Limit: truncateAt, Out: ios.Out, ErrOut: ios.ErrOut} + ow.Write(resp.LLMFacingText) + ow.Finish() + + return nil + }, + } + + cmd.Flags().StringVar(&searchSources, "source", "", "Filter by source type (comma-separated: slack,google_drive)") + cmd.Flags().IntVar(&searchDays, "days", 0, "Only return results from the last N days") + cmd.Flags().IntVar(&searchLimit, "limit", 0, "Maximum number of results (default: server decides)") + cmd.Flags().IntVar(&searchAgentID, "agent-id", 0, "Agent ID for scoped search") + cmd.Flags().BoolVar(&searchRaw, "raw", false, "Output full API response (results with scores, links, document IDs, citation mapping)") + cmd.Flags().BoolVar(&searchNoQueryExpansion, "no-query-expansion", false, "Skip LLM query expansion (faster, less comprehensive)") + cmd.Flags().IntVar(&maxOutput, "max-output", defaultMaxOutputBytes, + "Max bytes to print before truncating (0 to disable, auto-enabled for non-TTY, ignored with --raw)") + + return cmd +} diff --git a/cli/cmd/search_test.go b/cli/cmd/search_test.go new file mode 100644 index 0000000000..d0928f0189 --- /dev/null +++ b/cli/cmd/search_test.go @@ -0,0 +1,214 @@ +package cmd + +import ( + "bytes" + "errors" + "testing" + + "github.com/onyx-dot-app/onyx/cli/internal/exitcodes" + "github.com/onyx-dot-app/onyx/cli/internal/iostreams" + "github.com/spf13/cobra" +) + +func TestSearch_NoQuery(t *testing.T) { + ios := &iostreams.IOStreams{ + In: &bytes.Buffer{}, + Out: &bytes.Buffer{}, + ErrOut: &bytes.Buffer{}, + IsStdinTTY: true, + IsStdoutTTY: true, + } + cmd := newSearchCmd(ios) + cmd.SetArgs([]string{}) + + // Stub RunE so we don't need a real client, but keep the arg check. + origRunE := cmd.RunE + cmd.RunE = func(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return exitcodes.New(exitcodes.BadRequest, + "no query provided\n Usage: onyx-cli search \"your query\"") + } + return origRunE(cmd, args) + } + + err := cmd.Execute() + if err == nil { + t.Fatal("expected error for missing query") + } + var exitErr *exitcodes.ExitError + if !errors.As(err, &exitErr) { + t.Fatalf("want *ExitError, got %T: %v", err, err) + } + if exitErr.Code != exitcodes.BadRequest { + t.Errorf("exit code = %d, want %d", exitErr.Code, exitcodes.BadRequest) + } +} + +func TestBuildSearchRequest(t *testing.T) { + intPtr := func(v int) *int { return &v } + + tests := []struct { + name string + + query string + sources []string + days int + daysSet bool + limit int + limitSet bool + agentID int + agentIDSet bool + defaultAgentID int + noQueryExpansion bool + + wantSources []string + wantTimeCutoffDays *int + wantNumResults int + wantPersonaID *int + wantSkipQueryExpansion bool + }{ + { + name: "no_sources", + query: "test query", + wantSources: nil, + }, + { + name: "two_sources", + query: "test query", + sources: []string{"slack", "google_drive"}, + wantSources: []string{"slack", "google_drive"}, + }, + { + name: "empty_strings_filtered_from_sources", + query: "test query", + sources: []string{"slack", "", " ", "google_drive"}, + wantSources: []string{"slack", "google_drive"}, + }, + { + name: "days_limit_agentID_set", + query: "test query", + days: 30, + daysSet: true, + limit: 5, + limitSet: true, + agentID: 3, + agentIDSet: true, + wantTimeCutoffDays: intPtr(30), + wantNumResults: 5, + wantPersonaID: intPtr(3), + }, + { + name: "unset_flags_produce_zero_values", + query: "test query", + wantSources: nil, + wantTimeCutoffDays: nil, + wantNumResults: 0, + wantPersonaID: nil, + }, + { + name: "agent_id_zero_explicitly_set", + query: "test query", + agentID: 0, + agentIDSet: true, + wantPersonaID: intPtr(0), + }, + { + name: "no_query_expansion", + query: "exact error text", + noQueryExpansion: true, + wantSkipQueryExpansion: true, + }, + { + name: "default_agent_id_fallback", + query: "test query", + defaultAgentID: 7, + wantPersonaID: intPtr(7), + }, + { + name: "explicit_agent_id_overrides_default", + query: "test query", + agentID: 2, + agentIDSet: true, + defaultAgentID: 7, + wantPersonaID: intPtr(2), + }, + { + name: "default_agent_id_zero_not_sent", + query: "test query", + defaultAgentID: 0, + wantPersonaID: nil, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := buildSearchRequest( + tt.query, + tt.sources, + tt.days, tt.daysSet, + tt.limit, tt.limitSet, + tt.agentID, tt.agentIDSet, + tt.defaultAgentID, + tt.noQueryExpansion, + ) + + if req.Query != tt.query { + t.Errorf("Query = %q, want %q", req.Query, tt.query) + } + + // Sources + if tt.wantSources == nil { + if req.Sources != nil { + t.Errorf("Sources = %v, want nil", req.Sources) + } + } else { + if len(req.Sources) != len(tt.wantSources) { + t.Fatalf("Sources length = %d, want %d: %v", len(req.Sources), len(tt.wantSources), req.Sources) + } + for i, s := range tt.wantSources { + if req.Sources[i] != s { + t.Errorf("Sources[%d] = %q, want %q", i, req.Sources[i], s) + } + } + } + + // TimeCutoffDays + if tt.wantTimeCutoffDays == nil { + if req.TimeCutoffDays != nil { + t.Errorf("TimeCutoffDays = %v, want nil", *req.TimeCutoffDays) + } + } else { + if req.TimeCutoffDays == nil { + t.Fatalf("TimeCutoffDays = nil, want %d", *tt.wantTimeCutoffDays) + } + if *req.TimeCutoffDays != *tt.wantTimeCutoffDays { + t.Errorf("TimeCutoffDays = %d, want %d", *req.TimeCutoffDays, *tt.wantTimeCutoffDays) + } + } + + // NumResults + if req.NumResults != tt.wantNumResults { + t.Errorf("NumResults = %d, want %d", req.NumResults, tt.wantNumResults) + } + + // PersonaID + if tt.wantPersonaID == nil { + if req.PersonaID != nil { + t.Errorf("PersonaID = %d, want nil", *req.PersonaID) + } + } else { + if req.PersonaID == nil { + t.Fatalf("PersonaID = nil, want %d", *tt.wantPersonaID) + } + if *req.PersonaID != *tt.wantPersonaID { + t.Errorf("PersonaID = %d, want %d", *req.PersonaID, *tt.wantPersonaID) + } + } + + // SkipQueryExpansion + if req.SkipQueryExpansion != tt.wantSkipQueryExpansion { + t.Errorf("SkipQueryExpansion = %v, want %v", req.SkipQueryExpansion, tt.wantSkipQueryExpansion) + } + }) + } +} diff --git a/cli/internal/api/client.go b/cli/internal/api/client.go index c36e2e9654..7f0cce707a 100644 --- a/cli/internal/api/client.go +++ b/cli/internal/api/client.go @@ -79,7 +79,7 @@ func wrapTimeoutError(err error) error { return err } -func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error { +func (c *Client) doJSONWith(ctx context.Context, httpClient *http.Client, method, path string, reqBody any, result any) error { var body io.Reader if reqBody != nil { data, err := json.Marshal(reqBody) @@ -97,7 +97,7 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r req.Header.Set("Content-Type", "application/json") } - resp, err := c.httpClient.Do(req) + resp, err := httpClient.Do(req) if err != nil { return wrapTimeoutError(err) } @@ -113,6 +113,23 @@ func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, r return nil } +func (c *Client) doJSON(ctx context.Context, method, path string, reqBody any, result any) error { + return c.doJSONWith(ctx, c.httpClient, method, path, reqBody, result) +} + +func (c *Client) doJSONLong(ctx context.Context, method, path string, reqBody any, result any) error { + return c.doJSONWith(ctx, c.longHTTPClient, method, path, reqBody, result) +} + +// Search calls POST /api/search and returns the response. +func (c *Client) Search(ctx context.Context, req models.SearchRequest) (*models.SearchResponse, error) { + var resp models.SearchResponse + if err := c.doJSONLong(ctx, "POST", "/search", req, &resp); err != nil { + return nil, err + } + return &resp, nil +} + // TestConnection checks if the server is reachable and credentials are valid. // Returns nil on success, or an error with a descriptive message on failure. func (c *Client) TestConnection(ctx context.Context) error { @@ -315,6 +332,7 @@ type ClientAPI interface { GetBackendVersion(ctx context.Context) (string, error) StopChatSession(ctx context.Context, sessionID string) SendMessageStream(ctx context.Context, message string, chatSessionID *string, agentID int, parentMessageID *int, fileDescriptors []models.FileDescriptorPayload) <-chan models.StreamEvent + Search(ctx context.Context, req models.SearchRequest) (*models.SearchResponse, error) } var _ ClientAPI = (*Client)(nil) diff --git a/cli/internal/api/client_test.go b/cli/internal/api/client_test.go index 17ef91c440..d20ec5a34c 100644 --- a/cli/internal/api/client_test.go +++ b/cli/internal/api/client_test.go @@ -8,6 +8,7 @@ import ( "testing" "github.com/onyx-dot-app/onyx/cli/internal/api" + "github.com/onyx-dot-app/onyx/cli/internal/models" "github.com/onyx-dot-app/onyx/cli/internal/testutil" ) @@ -23,6 +24,57 @@ func TestListAgents_Timeout(t *testing.T) { } } +func TestSearch_Success(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("method = %s, want POST", r.Method) + } + if !strings.HasSuffix(r.URL.Path, "/search") { + t.Errorf("path = %s, want /api/search", r.URL.Path) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "results": [{"citation_id": 1, "document_id": "doc-1", "chunk_ind": 0, "title": "Test", "blurb": "blurb", "link": null, "source_type": "web", "score": 0.95, "updated_at": null}], + "llm_facing_text": "{\"results\": []}", + "citation_mapping": {"1": "doc-1"} + }`)) + })) + defer srv.Close() + + client := testutil.NewClient(srv.URL + "/api") + resp, err := client.Search(t.Context(), models.SearchRequest{Query: "test"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(resp.Results) != 1 { + t.Fatalf("expected 1 result, got %d", len(resp.Results)) + } + if resp.Results[0].DocumentID != "doc-1" { + t.Errorf("document_id = %q, want %q", resp.Results[0].DocumentID, "doc-1") + } + if resp.LLMFacingText == "" { + t.Error("llm_facing_text is empty") + } +} + +func TestSearch_401(t *testing.T) { + srv := testutil.StatusServer(401) + defer srv.Close() + + client := testutil.NewClient(srv.URL) + _, err := client.Search(t.Context(), models.SearchRequest{Query: "test"}) + if err == nil { + t.Fatal("expected error for 401") + } + var apiErr *api.OnyxAPIError + if !errors.As(err, &apiErr) { + t.Fatalf("want *OnyxAPIError, got %T: %v", err, err) + } + if apiErr.StatusCode != 401 { + t.Errorf("status = %d, want 401", apiErr.StatusCode) + } +} + // TestTestConnection_AWSELB403 verifies that TestConnection detects an AWS // ALB/ELB 403 by inspecting the Server response header. This header-sniffing // logic cannot be exercised by integration tests since it requires a specific diff --git a/cli/internal/embedded/SKILL.md b/cli/internal/embedded/SKILL.md index 4cfa8cf2b6..8d64eacf21 100644 --- a/cli/internal/embedded/SKILL.md +++ b/cli/internal/embedded/SKILL.md @@ -53,13 +53,48 @@ Exit code 0 on success. Non-zero with a descriptive error on failure (see exit c ## Commands +### Search documents + +```bash +onyx-cli search "What is our deployment process?" +``` + +Returns ranked, cited documents from the Onyx knowledge base as JSON. The default output is the LLM-facing format — `{"results": [...]}` with citation IDs, titles, content, and source types. Use `--raw` for the full API response including document IDs, scores, links, and citation mapping. + +```bash +# Filter by source +onyx-cli search --source slack,google_drive "auth migration status" + +# Recent results only +onyx-cli search --days 30 --limit 5 "recent production incidents" + +# Use a specific agent for scoped search +onyx-cli search --agent-id 5 "engineering roadmap" + +# Full API response for programmatic use +onyx-cli search --raw "API documentation" | jq '.results[].title' + +# Skip query expansion for exact matching +onyx-cli search --no-query-expansion "exact error message text" +``` + +| Flag | Type | Description | +| ----------------------- | ------ | ---------------------------------------------------------------- | +| `--source` | string | Filter by source type (comma-separated: slack,google_drive) | +| `--days` | int | Only return results from the last N days | +| `--limit` | int | Maximum number of results (default: server decides) | +| `--agent-id` | int | Agent ID for scoped search (inherits filters, document sets) | +| `--raw` | bool | Output full API response (results with scores, links, document IDs, citation mapping) | +| `--no-query-expansion` | bool | Skip LLM query expansion (faster, less comprehensive) | +| `--max-output` | int | Max bytes to print before truncating (0 to disable, default 4096 for non-TTY, ignored with --raw) | + ### Ask a question ```bash onyx-cli ask "What is our company's PTO policy?" ``` -Streams the answer as plain text to stdout. When stdout is not a TTY, output is truncated to 4096 bytes and the full response is saved to a temp file (path printed at the end). Use `--max-output 0` to disable truncation. +Streams an LLM-generated answer as plain text to stdout. Use `search` instead when you need the source documents rather than a synthesized answer. When stdout is not a TTY, output is truncated to 4096 bytes and the full response is saved to a temp file (path printed at the end). Use `--max-output 0` to disable truncation. ```bash # Use a specific agent @@ -87,7 +122,7 @@ onyx-cli agents onyx-cli agents --json ``` -Prints a table of agent IDs, names, and descriptions. Use `--json` for structured JSON output. Use agent IDs with `ask --agent-id`. +Prints a table of agent IDs, names, and descriptions. Use `--json` for structured JSON output. Use agent IDs with `search --agent-id` or `ask --agent-id`. ### Validate configuration @@ -95,14 +130,14 @@ Prints a table of agent IDs, names, and descriptions. Use `--json` for structure onyx-cli validate-config ``` -Checks config exists, PAT is present, server is reachable, and credentials are valid. Use before `ask` or `agents` to confirm the CLI is properly set up. +Checks config exists, PAT is present, server is reachable, and credentials are valid. Use before `search`, `ask`, or `agents` to confirm the CLI is properly set up. ## Output Conventions - **stdout**: Results only (answer text, agent list, status) - **stderr**: Progress indicators, warnings, errors - **Non-TTY**: No ANSI escape codes, no interactive prompts -- **Truncation**: When stdout is not a TTY, `ask` output is truncated to 4096 bytes. Full response is saved to a temp file whose path is printed. Read the temp file for more. +- **Truncation**: When stdout is not a TTY, `search` and `ask` output is truncated to 4096 bytes. Full response is saved to a temp file whose path is printed. Read the temp file for more. ## Exit Codes @@ -121,17 +156,22 @@ Checks config exists, PAT is present, server is reachable, and credentials are v ## Statelessness -Each `onyx-cli ask` call creates an independent chat session. There is no way to chain context across multiple invocations — every call starts fresh. +Each invocation is independent. `search` does not create a chat session. `ask` creates a one-shot chat session. There is no way to chain context across multiple invocations — every call starts fresh. ## When to Use -Use `onyx-cli ask` when: -- The user asks about company-specific information (policies, docs, processes) -- You need to search internal knowledge bases or connected data sources -- The user references Onyx or wants to query their documents -- You need context from company wikis, Confluence, Google Drive, Slack, or other connected sources +Use `onyx-cli search` when: +- You need to find specific documents or gather context for a task +- You want to reason over multiple source documents yourself +- The user asks you to look up or find information in company knowledge +- You need cited, structured results (document IDs, source types, content) -Do NOT use when: +Use `onyx-cli ask` when: +- The user wants a direct answer, summarization, or synthesis +- A human-readable response is more useful than raw documents +- You need the LLM to reason across sources and produce an answer + +Do NOT use either when: - The question is about general programming knowledge (use your own knowledge) - The user is asking about code in the current repository (use grep/read tools) - The user hasn't mentioned Onyx and the question doesn't require internal company data @@ -139,16 +179,13 @@ Do NOT use when: ## Examples ```bash -# Simple question +# Search for documents +onyx-cli search "What is our deployment process?" +onyx-cli search --source slack "auth migration status" +onyx-cli search --raw "API documentation" | jq '.results[].title' + +# Ask for an answer onyx-cli ask "What are the steps to deploy to production?" - -# Use a specialized agent onyx-cli ask --agent-id 3 "What were the action items from last week's standup?" - -# Pipe context with a question cat error.log | onyx-cli ask --prompt "What does this error mean?" - -# Read the full response when truncated -onyx-cli ask "Describe the full onboarding process" 2>/dev/null -# If truncated, read the temp file path from the last line of output ``` diff --git a/cli/internal/exitcodes/codes.go b/cli/internal/exitcodes/codes.go index 55e6131bf6..b8f44ccf8a 100644 --- a/cli/internal/exitcodes/codes.go +++ b/cli/internal/exitcodes/codes.go @@ -24,7 +24,7 @@ func ForHTTPStatus(statusCode int) Code { switch { case statusCode >= 200 && statusCode < 300: return Success - case statusCode == 400: + case statusCode == 400 || statusCode == 422: return BadRequest case statusCode == 401 || statusCode == 403: return AuthFailure diff --git a/cli/internal/exitcodes/codes_test.go b/cli/internal/exitcodes/codes_test.go index ce6260baac..c01c816f96 100644 --- a/cli/internal/exitcodes/codes_test.go +++ b/cli/internal/exitcodes/codes_test.go @@ -64,6 +64,7 @@ func TestForHTTPStatus(t *testing.T) { }{ {200, Success}, {400, BadRequest}, + {422, BadRequest}, {401, AuthFailure}, {403, AuthFailure}, {404, NotAvailable}, diff --git a/cli/internal/models/models.go b/cli/internal/models/models.go index 21388a6295..56947504e3 100644 --- a/cli/internal/models/models.go +++ b/cli/internal/models/models.go @@ -100,3 +100,34 @@ type Placement struct { TabIndex int `json:"tab_index"` SubTurnIndex *int `json:"sub_turn_index"` } + +// SearchRequest is the request body for POST /api/search. +type SearchRequest struct { + Query string `json:"query"` + Sources []string `json:"sources,omitempty"` + DocumentSets []string `json:"document_sets,omitempty"` + TimeCutoffDays *int `json:"time_cutoff_days,omitempty"` + NumResults int `json:"num_results,omitempty"` + PersonaID *int `json:"persona_id,omitempty"` + SkipQueryExpansion bool `json:"skip_query_expansion,omitempty"` +} + +// SearchResult is a single document result from the search API. +type SearchResult struct { + CitationID *int `json:"citation_id"` + DocumentID string `json:"document_id"` + ChunkInd int `json:"chunk_ind"` + Title string `json:"title"` + Blurb string `json:"blurb"` + Link *string `json:"link"` + SourceType string `json:"source_type"` + Score *float64 `json:"score"` + UpdatedAt *string `json:"updated_at"` +} + +// SearchResponse is the response from POST /api/search. +type SearchResponse struct { + Results []SearchResult `json:"results"` + LLMFacingText string `json:"llm_facing_text"` + CitationMapping map[int]string `json:"citation_mapping"` +} diff --git a/docs/craft/features/search/3-cli-search-command.md b/docs/craft/features/search/3-cli-search-command.md index 2f5c250240..379bc558da 100644 --- a/docs/craft/features/search/3-cli-search-command.md +++ b/docs/craft/features/search/3-cli-search-command.md @@ -94,7 +94,7 @@ type SearchRequest struct { // SearchResult is a single document result from the search API. type SearchResult struct { - CitationID int `json:"citation_id"` + CitationID *int `json:"citation_id"` DocumentID string `json:"document_id"` ChunkInd int `json:"chunk_ind"` Title string `json:"title"`