mirror of
https://github.com/onyx-dot-app/onyx.git
synced 2026-10-19 01:50:51 -05:00
feat(onyx-cli): agentic search command (#11001)
This commit is contained in:
1 parent
95a838c568
commit
d44015d4e4
11 files changed
+735
-24
No files matched your search
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
```
|
||||
@@ -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
|
||||
|
||||
@@ -64,6 +64,7 @@ func TestForHTTPStatus(t *testing.T) {
|
||||
}{
|
||||
{200, Success},
|
||||
{400, BadRequest},
|
||||
{422, BadRequest},
|
||||
{401, AuthFailure},
|
||||
{403, AuthFailure},
|
||||
{404, NotAvailable},
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
Reference in new issue
Block a user