diff --git a/AGENTS.md b/AGENTS.md index 2a4d7b85..110fdafd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -128,9 +128,9 @@ Use narrower verification for small edits. ## Agent Studio (`pkg/cmd/agents/...`, `api/agentstudio/`) -Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `update`, `delete`, `publish`, `unpublish`, `duplicate`, `try`, `run`. Backend source of truth: `github.com/algolia/conversational-ai`. +Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `update`, `delete`, `publish`, `unpublish`, `duplicate`, `try`, `run`. Sub-groups: `cache` (`invalidate`). Backend source of truth: `github.com/algolia/conversational-ai`. -**Naming note**: `try` (not `test`) — see "On `--dry-run`" below for why. All other verbs are single-word lowercase to match the CLI-wide convention; no hyphenated subcommand names exist anywhere in the tree. +**Naming note**: `try` (not `test`) — see "On `--dry-run`" below for why. All flat verbs are single-word lowercase to match the CLI-wide convention; no hyphenated subcommand names exist anywhere in the tree. Sub-groups (`cache`, future `providers` / `conversations` / `keys` / `domains`) read as noun-then-verb (`agents cache invalidate`) — also a single word per token. ### API client (`api/agentstudio/`) @@ -139,6 +139,9 @@ Top-level command group: `algolia agents`. Verbs: `list`, `get`, `create`, `upda - Errors: `*APIError` with `StatusCode`, `Detail`, optional `Sentinel`. The detail extractor prefers structured FastAPI `detail[].msg` arrays over the generic `message` field — backends that return both pair them as `{"message":"Input is invalid, see detail/body:","detail":[{"msg":"..."}]}` and the structured form is the actionable one. - `CreateAgent` / `UpdateAgent` accept `json.RawMessage` bodies on purpose. The backend's `AgentConfigCreate` schema is large, deeply validated, and evolves often. The CLI is a pass-through; the backend validates; our 422-detail surfacing makes errors actionable. - `Completions(...)` returns the raw `*http.Response`. Caller checks `Content-Type` (`text/event-stream` → `ParseStream`; else copy verbatim). One method, two output shapes. +- `CompletionOptions.No*` fields (`NoCache`, `NoMemory`, `NoAnalytics`) are **inverted** from the backend's query polarity. Two reasons: the backend defaults all three to true (only the negative is interesting at the CLI), and `memory` in particular has an `anyOf [{const false}, {type null}]` schema — sending `memory=true` would 422. Therefore the wire form omits the param when the No* field is false, and sends `=false` when true. Polarity is enforced end-to-end by `TestCompletions_QueryFlagsAndSecureUserToken` in `api/agentstudio/completions_test.go`. +- `CompletionOptions.SecureUserToken` populates the `X-Algolia-Secure-User-Token` header when non-empty. It carries a signed JWT scoping the conversation/memory/analytics partition to a specific end-user (see `rag/dependencies/secure_user_token.py` in the backend). Empty means no header — `X-Algolia-User-ID` fallback applies. +- `InvalidateAgentCache(id, before)` calls `DELETE /1/agents/{id}/cache?before=YYYY-MM-DD` (query omitted when `before` is empty). Date format validation is **deliberately not done client-side** — the backend's Pydantic parser is the source of truth, and our 422 surfacing turns malformed input into an actionable message verbatim. Mirroring the parser in Go would create silent skew. ### Streaming (`api/agentstudio/sse.go`) @@ -151,6 +154,21 @@ The wire format is **not** standard SSE. Two protocols, both served as `text/eve Streaming output convention: NDJSON to stdout regardless of TTY, one `{"type":"...","data":{...}}` per line. Plays well with `jq -r 'select(.type=="text-delta") | .data.delta'`. Don't fork rendering between TTY/non-TTY for streaming responses. +### Completion runtime knobs (`agents try` / `agents run`) + +Both commands expose the same set of completion-time flags, mapping directly to backend query params + headers: + +| Flag | Wire | Default | Notes | +|---|---|---|---| +| `--no-stream` | `?stream=false` | stream | Buffered single-JSON response instead of SSE | +| `--compatibility v4\|v5` | `?compatibilityMode=ai-sdk-{4,5}` | v5 | **Required** server-side; CLI promotes empty → v5 | +| `--no-cache` | `?cache=false` | cache on | Bypasses backend completion cache for this call | +| `--no-memory` | `?memory=false` | memory on | Disables agent memory retrieval/write for this call | +| `--no-analytics` | `?analytics=false` | analytics on | Skips Agent Studio analytics for this call | +| `--secure-user-token ` | `X-Algolia-Secure-User-Token` header | (omitted) | Signed JWT, end-user scoping | + +The flag set is intentionally duplicated across `try.go` and `run.go` rather than extracted into a `RegisterCompletionFlags` shared helper — there are exactly two consumers and the duplication is mechanical (8 lines per command). If a third consumer appears, extract following the "second use" rule (same as `PrintDryRun` / `NormalizeCompatibility`). + ### On `--dry-run` Two distinct concepts share the name and they MUST NOT be conflated: diff --git a/api/agentstudio/client.go b/api/agentstudio/client.go index 358384d3..efe02896 100644 --- a/api/agentstudio/client.go +++ b/api/agentstudio/client.go @@ -130,19 +130,46 @@ func (c *Client) ListAgents(ctx context.Context, params ListAgentsParams) (*Pagi return &out, nil } -// CompletionOptions configures Completions(...) query parameters. +// CompletionOptions configures Completions(...) query parameters and +// per-request headers. // // Stream maps to ?stream=true|false; the default zero value (false) gives // a buffered single-JSON response. Set explicitly via the command layer -// (`agents test` / `agents run` set Stream=true unless --no-stream). +// (`agents try` / `agents run` set Stream=true unless --no-stream). // // Compatibility maps to ?compatibilityMode=ai-sdk-4|ai-sdk-5. The backend // requires this query param (no server-side default), so empty here is // promoted to CompatV5 — its frames are standard SSE with [DONE], easier // to parse defensively than v4's `:\n` line format. +// +// NoCache, NoMemory, and NoAnalytics are inverted from the backend's +// query-param polarity for two reasons: +// +// - The backend defaults all three to true; only the negated case is +// interesting from the CLI surface. +// - The flag layer ships them as `--no-cache`/`--no-memory`/`--no-analytics` +// so the option fields keep that polarity end-to-end. +// +// When a No*-field is false (the zero value) the corresponding query +// param is omitted entirely, which matches the backend's "default ON" +// behavior. The `memory` schema in particular is `anyOf [{const: false}, +// {type: null}]` — false is the ONLY valid passable value, so always +// emitting `memory=true` would be a server-side validation error. +// +// SecureUserToken populates the X-Algolia-Secure-User-Token header when +// non-empty. It carries a signed JWT that scopes the conversation / +// memory / analytics partition to a specific end-user; required by the +// backend whenever a feature behind SecureUserTokenDep is enabled (see +// rag/dependencies/secure_user_token.py in algolia/conversational-ai). +// Empty here means no header is sent — the existing X-Algolia-User-ID +// fallback applies. type CompletionOptions struct { - Stream bool - Compatibility CompatibilityMode + Stream bool + Compatibility CompatibilityMode + NoCache bool + NoMemory bool + NoAnalytics bool + SecureUserToken string } // Completions calls POST /1/agents/{agentID}/completions and returns the @@ -191,6 +218,19 @@ func (c *Client) Completions( q := url.Values{} q.Set("stream", boolToWire(opts.Stream)) q.Set("compatibilityMode", string(mode)) + // Only emit the negative cases — backend defaults match the omitted + // state, so adding `cache=true`/`analytics=true` would be wire noise, + // and `memory=true` would actually be a 422 (the schema only allows + // `false` or null). See CompletionOptions godoc for the full reasoning. + if opts.NoCache { + q.Set("cache", "false") + } + if opts.NoMemory { + q.Set("memory", "false") + } + if opts.NoAnalytics { + q.Set("analytics", "false") + } endpoint := c.cfg.BaseURL + "/1/agents/" + url.PathEscape(agentID) + "/completions?" + q.Encode() @@ -200,6 +240,9 @@ func (c *Client) Completions( } c.setHeaders(req) req.Header.Set("Content-Type", "application/json") + if opts.SecureUserToken != "" { + req.Header.Set("X-Algolia-Secure-User-Token", opts.SecureUserToken) + } // Preferred Accept: streaming responses come back as text/event-stream // (both v4 and v5); buffered ones as application/json. Listing both // is safe — the server picks based on ?stream and we inspect the @@ -260,6 +303,48 @@ func (c *Client) UpdateAgent(ctx context.Context, id string, body json.RawMessag return c.doAgentMutation(ctx, http.MethodPatch, endpoint, body, "update agent") } +// InvalidateAgentCache calls DELETE /1/agents/{id}/cache. The backend +// removes cached completion responses for this agent. +// +// `before` is an optional YYYY-MM-DD date string. When non-empty, only +// cache entries created strictly before that date are invalidated +// (exclusive). When empty, all cache entries for the agent are wiped. +// +// The format is intentionally not pre-parsed in Go — the backend +// accepts the literal string and returns a 422 with a structured detail +// on a malformed value, which our extractDetail surfaces unchanged. Any +// client-side date parsing here would diverge from whatever Pydantic +// version the backend ships and create silent skew. +// +// Returns nil on the backend's HTTP 204 No Content. Wraps the standard +// 4xx/5xx APIError otherwise. +func (c *Client) InvalidateAgentCache(ctx context.Context, id, before string) error { + if strings.TrimSpace(id) == "" { + return fmt.Errorf("agent studio: agent id is required") + } + + endpoint := c.cfg.BaseURL + "/1/agents/" + url.PathEscape(id) + "/cache" + if before != "" { + q := url.Values{} + q.Set("before", before) + endpoint += "?" + q.Encode() + } + + req, err := http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil) + if err != nil { + return err + } + c.setHeaders(req) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("agent studio: invalidate agent cache: %w", err) + } + defer resp.Body.Close() + + return checkResponse(resp) +} + // DeleteAgent calls DELETE /1/agents/{id}. // // Returns nil on the backend's HTTP 204 No Content. The backend diff --git a/api/agentstudio/completions_test.go b/api/agentstudio/completions_test.go index 6ed6600a..5ff2785c 100644 --- a/api/agentstudio/completions_test.go +++ b/api/agentstudio/completions_test.go @@ -157,6 +157,81 @@ func readAllString(t *testing.T, r io.Reader) string { return strings.TrimSpace(string(b)) } +func TestCompletions_QueryFlagsAndSecureUserToken(t *testing.T) { + // Phase 5: validates the new --no-cache / --no-memory / --no-analytics + // / --secure-user-token plumbing all the way through the wire. + // + // Polarity matters here: the No*-fields are inverted from the + // backend's query polarity (see CompletionOptions godoc). A `false` + // value MUST omit the param — sending `cache=true` would still + // match server defaults, but sending `memory=true` would 422 (the + // `memory` schema only allows {const false, null}). This is the + // regression net for that. + cases := []struct { + name string + opts CompletionOptions + wantHas map[string]string // params that must equal a value + wantNot []string // params that must be ABSENT + wantHdr string // expected X-Algolia-Secure-User-Token; "" = absent + }{ + { + name: "all defaults: only stream + compatibilityMode set", + opts: CompletionOptions{Stream: true}, + wantHas: map[string]string{"stream": "true", "compatibilityMode": "ai-sdk-5"}, + wantNot: []string{"cache", "memory", "analytics"}, + wantHdr: "", + }, + { + name: "--no-cache only", + opts: CompletionOptions{Stream: true, NoCache: true}, + wantHas: map[string]string{"cache": "false"}, + wantNot: []string{"memory", "analytics"}, + }, + { + name: "--no-memory only (the most semantically constrained)", + opts: CompletionOptions{Stream: true, NoMemory: true}, + wantHas: map[string]string{"memory": "false"}, + wantNot: []string{"cache", "analytics"}, + }, + { + name: "--no-analytics only", + opts: CompletionOptions{Stream: true, NoAnalytics: true}, + wantHas: map[string]string{"analytics": "false"}, + wantNot: []string{"cache", "memory"}, + }, + { + name: "all three negative + secure user token header", + opts: CompletionOptions{Stream: true, NoCache: true, NoMemory: true, NoAnalytics: true, SecureUserToken: "ey.signed.jwt"}, + wantHas: map[string]string{"cache": "false", "memory": "false", "analytics": "false"}, + wantHdr: "ey.signed.jwt", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/test/completions", func(w http.ResponseWriter, r *http.Request) { + for k, v := range tc.wantHas { + assert.Equal(t, v, r.URL.Query().Get(k), "query param %q", k) + } + for _, k := range tc.wantNot { + assert.False(t, r.URL.Query().Has(k), "query param %q must be absent", k) + } + assert.Equal(t, tc.wantHdr, r.Header.Get("X-Algolia-Secure-User-Token")) + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{}`)) + }) + _, c := newTestClient(t, mux) + + resp, err := c.Completions(context.Background(), "test", + json.RawMessage(`{"messages":[{"role":"user","content":"x"}]}`), tc.opts) + require.NoError(t, err) + _ = resp.Body.Close() + }) + } +} + func TestCompletions_BodyContentRoundTrip(t *testing.T) { // Confirms we POST exactly the bytes we were handed (no re-encode). wire := `{"messages":[{"role":"user","content":"x"}],"id":"conv-1"}` diff --git a/api/agentstudio/mutations_test.go b/api/agentstudio/mutations_test.go index d2c6914d..a2173a42 100644 --- a/api/agentstudio/mutations_test.go +++ b/api/agentstudio/mutations_test.go @@ -223,6 +223,93 @@ func TestLifecycle_RejectsEmptyID(t *testing.T) { } } +func TestInvalidateAgentCache(t *testing.T) { + cases := []struct { + name string + id string + before string + serverFn func(t *testing.T) http.HandlerFunc + wantErr string // substring; "" = expect success + isSentinel error + }{ + { + name: "no before -> DELETE without query", + id: "abc-123", + before: "", + serverFn: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "", r.URL.RawQuery, "no before -> no query string") + w.WriteHeader(http.StatusNoContent) + } + }, + }, + { + name: "with before -> DELETE with ?before", + id: "abc-123", + before: "2026-01-15", + serverFn: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "2026-01-15", r.URL.Query().Get("before")) + w.WriteHeader(http.StatusNoContent) + } + }, + }, + { + name: "404 from backend surfaces as ErrNotFound", + id: "missing", + before: "", + serverFn: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Agent not found"}`)) + } + }, + wantErr: "Agent not found", + isSentinel: ErrNotFound, + }, + { + name: "422 with structured detail (e.g. malformed before) surfaces backend message verbatim", + id: "abc-123", + before: "not-a-date", + serverFn: func(t *testing.T) http.HandlerFunc { + return func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + _, _ = w.Write([]byte(`{"detail":[{"msg":"Input should be a valid date in YYYY-MM-DD format","loc":["query","before"]}]}`)) + } + }, + wantErr: "valid date", + }, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/"+tc.id+"/cache", tc.serverFn(t)) + _, c := newTestClient(t, mux) + + err := c.InvalidateAgentCache(context.Background(), tc.id, tc.before) + if tc.wantErr == "" { + require.NoError(t, err) + return + } + require.Error(t, err) + assert.Contains(t, err.Error(), tc.wantErr) + if tc.isSentinel != nil { + assert.True(t, errors.Is(err, tc.isSentinel)) + } + }) + } +} + +func TestInvalidateAgentCache_RejectsEmptyID(t *testing.T) { + _, c := newTestClient(t, http.NewServeMux()) + err := c.InvalidateAgentCache(context.Background(), " ", "") + require.Error(t, err) + assert.Contains(t, err.Error(), "agent id is required") +} + func TestLifecycle_NotFound(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/agents/missing/publish", func(w http.ResponseWriter, _ *http.Request) { diff --git a/e2e/testscripts/agents/cache.txtar b/e2e/testscripts/agents/cache.txtar new file mode 100644 index 00000000..51328e4d --- /dev/null +++ b/e2e/testscripts/agents/cache.txtar @@ -0,0 +1,33 @@ +# `agents cache invalidate` — flag-validation + dry-run contract. +# None of these hit the network: the failing cases trip cobra/our own +# validators before client construction; the passing case uses --dry-run. +# +# Ungated: runs whenever the standard ALGOLIA_APPLICATION_ID + +# ALGOLIA_API_KEY are present. + +# `cache` parent without verb prints help (cobra default for groups +# with no Run; exits 0). +exec algolia agents cache +stdout 'invalidate' + +# `invalidate` requires positional +! exec algolia agents cache invalidate +stderr 'requires exactly 1 argument' + +# Non-TTY without --confirm is refused (matches `agents delete` rule) +! exec algolia agents cache invalidate abc-123 +stderr '--confirm required' + +# --dry-run skips the confirm requirement and prints the would-be call +exec algolia agents cache invalidate abc-123 --dry-run +! stderr . +stdout 'Dry run: would DELETE /1/agents/abc-123/cache' +stdout 'all cached completions for this agent' +! stdout '\?before=' + +# --dry-run with --before describes the bounded scope and includes the +# query string in the previewed URL +exec algolia agents cache invalidate abc-123 --before 2026-01-15 --dry-run +! stderr . +stdout 'Dry run: would DELETE /1/agents/abc-123/cache\?before=2026-01-15' +stdout 'before 2026-01-15' diff --git a/e2e/testscripts/agents/dry-run.txtar b/e2e/testscripts/agents/dry-run.txtar index ca777b10..baf510e9 100644 --- a/e2e/testscripts/agents/dry-run.txtar +++ b/e2e/testscripts/agents/dry-run.txtar @@ -57,6 +57,23 @@ stdout -count=1 '"messages"' ! exec algolia agents try -c cfg.json -m hi --dry-run stderr 'unknown flag' +# --------------------------------------------------------------------- +# Phase 5 completion runtime flags wire into the dry-run preview body +# without changing it (they're query params + a header, not body +# fields). Same for `agents try` which does NOT echo them via +# --dry-run because it has no --dry-run. The body preview must remain +# stable across these flag combos so users can diff configs cleanly. +# --------------------------------------------------------------------- +exec algolia agents run 11111111-1111-1111-1111-111111111111 -m hi --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt --dry-run +! stderr . +stdout -count=1 'Dry run: would POST /1/agents/11111111-1111-1111-1111-111111111111/completions' +stdout -count=1 '"content": "hi"' +# completion-runtime knobs are wire-only; never in the body +! stdout '"cache"' +! stdout '"memory"' +! stdout '"analytics"' +! stdout 'secure-user-token' + -- cfg.json -- {"name":"e2e-create-stub","instructions":"e2e","widgetType":"filterSuggestions","enableAlgoliaMcp":false} diff --git a/pkg/cmd/agents/agents.go b/pkg/cmd/agents/agents.go index d5356396..8ca1274d 100644 --- a/pkg/cmd/agents/agents.go +++ b/pkg/cmd/agents/agents.go @@ -4,6 +4,7 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/spf13/cobra" + "github.com/algolia/cli/pkg/cmd/agents/cache" "github.com/algolia/cli/pkg/cmd/agents/create" deletecmd "github.com/algolia/cli/pkg/cmd/agents/delete" "github.com/algolia/cli/pkg/cmd/agents/duplicate" @@ -63,6 +64,7 @@ func NewAgentsCmd(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(duplicate.NewDuplicateCmd(f, nil)) cmd.AddCommand(trycmd.NewTryCmd(f, nil)) cmd.AddCommand(run.NewRunCmd(f, nil)) + cmd.AddCommand(cache.NewCacheCmd(f)) return cmd } diff --git a/pkg/cmd/agents/cache/cache.go b/pkg/cmd/agents/cache/cache.go new file mode 100644 index 00000000..81a1a8f4 --- /dev/null +++ b/pkg/cmd/agents/cache/cache.go @@ -0,0 +1,182 @@ +package cache + +import ( + "context" + "fmt" + + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" + + "github.com/algolia/cli/api/agentstudio" + "github.com/algolia/cli/pkg/cmdutil" + "github.com/algolia/cli/pkg/iostreams" + "github.com/algolia/cli/pkg/prompt" + "github.com/algolia/cli/pkg/validators" +) + +// NewCacheCmd is the parent for `algolia agents cache `. +// +// Today there's exactly one verb (`invalidate`); kept as a sub-group +// rather than a flat `agents cache-invalidate` because: +// - The parent reads as a noun (`cache`), the child as a verb +// (`invalidate`) — natural language order, matches the established +// CLI rhythm (`apikeys list`, `objects update`). +// - Reserves a clean home for follow-ups (`agents cache stats`, +// `agents cache size`, etc.) without renaming the existing surface. +// - Mirrors the nested-group pattern Phase 6+ will reuse for +// `agents providers ` and `agents conversations `. +func NewCacheCmd(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "cache", + Short: "Inspect and invalidate Agent Studio completion caches", + Long: heredoc.Doc(` + Manage cached completion responses for an agent. + + Agent Studio caches completions per (agent, request hash). Use + --no-cache on "agents try" / "agents run" to bypass for a single + call; use "agents cache invalidate" to drop entries server-side. + `), + } + + cmd.AddCommand(newInvalidateCmd(f, nil)) + return cmd +} + +// InvalidateOptions configures `algolia agents cache invalidate`. +type InvalidateOptions struct { + IO *iostreams.IOStreams + Ctx context.Context + + AgentStudioClient func() (*agentstudio.Client, error) + + AgentID string + Before string + DryRun bool + DoConfirm bool +} + +func newInvalidateCmd(f *cmdutil.Factory, runF func(*InvalidateOptions) error) *cobra.Command { + opts := &InvalidateOptions{ + IO: f.IOStreams, + AgentStudioClient: f.AgentStudioClient, + } + + var confirm bool + + cmd := &cobra.Command{ + Use: "invalidate [--before YYYY-MM-DD] [--confirm]", + Short: "Invalidate cached completions for an agent", + Long: heredoc.Doc(` + Calls DELETE /1/agents//cache. + + By default, drops every cached completion for the agent. Pass + --before YYYY-MM-DD to drop only entries created strictly + before that date (exclusive — matches the backend's Pydantic + parsing). Date validation is the backend's job; bad input + surfaces a structured 422 verbatim. + + Like "agents delete", interactive use prompts to confirm and + non-interactive use requires --confirm. Use --dry-run to + preview without deleting. + `), + Example: heredoc.Doc(` + # Wipe all cached completions for an agent (interactive) + $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 + + # Drop only entries older than a specific date + $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 --before 2026-01-15 + + # Skip the prompt (required in CI) + $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 -y + + # Preview without sending + $ algolia agents cache invalidate 11111111-1111-1111-1111-111111111111 --dry-run + `), + Args: validators.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + opts.AgentID = args[0] + opts.Ctx = cmd.Context() + if opts.AgentID == "" { + return cmdutil.FlagErrorf("agent-id must not be empty") + } + + // Mirror agents delete: --confirm/-y bypasses prompt. + // Without it, prompt in TTY, refuse in non-TTY. --dry-run + // is non-destructive and bypasses the confirmation entirely. + if !confirm && !opts.DryRun { + if !opts.IO.CanPrompt() { + return cmdutil.FlagErrorf( + "--confirm required when non-interactive shell is detected", + ) + } + opts.DoConfirm = true + } + + if runF != nil { + return runF(opts) + } + return runInvalidateCmd(opts) + }, + } + + cmd.Flags(). + StringVar(&opts.Before, "before", "", "Drop entries strictly before this date (YYYY-MM-DD, exclusive)") + cmd.Flags().BoolVarP(&confirm, "confirm", "y", false, "Skip confirmation prompt") + cmd.Flags(). + BoolVar(&opts.DryRun, "dry-run", false, "Print what would be invalidated without calling the API") + + return cmd +} + +func runInvalidateCmd(opts *InvalidateOptions) error { + if opts.DryRun { + fmt.Fprintf(opts.IO.Out, "Dry run: would DELETE /1/agents/%s/cache", opts.AgentID) + if opts.Before != "" { + fmt.Fprintf(opts.IO.Out, "?before=%s", opts.Before) + } + fmt.Fprintln(opts.IO.Out) + if opts.Before == "" { + fmt.Fprintln(opts.IO.Out, " scope: all cached completions for this agent") + } else { + fmt.Fprintf(opts.IO.Out, " scope: cached completions created before %s\n", opts.Before) + } + return nil + } + + if opts.DoConfirm { + var confirmed bool + msg := fmt.Sprintf("Invalidate completion cache for agent %s?", opts.AgentID) + if opts.Before != "" { + msg = fmt.Sprintf("Invalidate completion cache for agent %s (entries before %s)?", + opts.AgentID, opts.Before) + } + if err := prompt.Confirm(msg, &confirmed); err != nil { + return fmt.Errorf("failed to prompt: %w", err) + } + if !confirmed { + return nil + } + } + + client, err := opts.AgentStudioClient() + if err != nil { + return err + } + ctx := opts.Ctx + if ctx == nil { + ctx = context.Background() + } + + opts.IO.StartProgressIndicatorWithLabel("Invalidating agent cache") + err = client.InvalidateAgentCache(ctx, opts.AgentID, opts.Before) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + cs := opts.IO.ColorScheme() + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.Out, "%s Invalidated cache for agent %s\n", cs.SuccessIcon(), opts.AgentID) + } + return nil +} diff --git a/pkg/cmd/agents/cache/cache_test.go b/pkg/cmd/agents/cache/cache_test.go new file mode 100644 index 00000000..1838a5a3 --- /dev/null +++ b/pkg/cmd/agents/cache/cache_test.go @@ -0,0 +1,131 @@ +package cache + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/algolia/cli/api/agentstudio" + "github.com/algolia/cli/test" +) + +func newClientForServer(t *testing.T, ts *httptest.Server) func() (*agentstudio.Client, error) { + t.Helper() + return func() (*agentstudio.Client, error) { + return agentstudio.NewClient(agentstudio.Config{ + BaseURL: ts.URL, + ApplicationID: "APP123", + APIKey: "k", + HTTPClient: ts.Client(), + }) + } +} + +func Test_runInvalidateCmd_NoBefore_HitsBackendWithoutQuery(t *testing.T) { + mux := http.NewServeMux() + hit := false + mux.HandleFunc("/1/agents/abc-123/cache", func(w http.ResponseWriter, r *http.Request) { + hit = true + assert.Equal(t, http.MethodDelete, r.Method) + assert.Equal(t, "", r.URL.RawQuery) + w.WriteHeader(http.StatusNoContent) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewCacheCmd(f) + _, err := test.Execute(cmd, "invalidate abc-123 -y", out) + require.NoError(t, err) + assert.True(t, hit) +} + +func Test_runInvalidateCmd_WithBefore_PassesQueryParam(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/abc-123/cache", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "2026-01-15", r.URL.Query().Get("before")) + w.WriteHeader(http.StatusNoContent) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewCacheCmd(f) + _, err := test.Execute(cmd, "invalidate abc-123 --before 2026-01-15 -y", out) + require.NoError(t, err) +} + +func Test_runInvalidateCmd_DryRunSkipsAPI(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/abc-123/cache", func(_ http.ResponseWriter, _ *http.Request) { + t.Fatal("backend was called during --dry-run") + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewCacheCmd(f) + result, err := test.Execute(cmd, "invalidate abc-123 --before 2026-01-15 --dry-run", out) + require.NoError(t, err) + + got := result.String() + assert.Contains(t, got, "Dry run: would DELETE /1/agents/abc-123/cache?before=2026-01-15") + assert.Contains(t, got, "scope: cached completions created before 2026-01-15") +} + +func Test_runInvalidateCmd_DryRunNoBefore_DescribesAllScope(t *testing.T) { + f, out := test.NewFactory(false, nil, nil, "") + cmd := NewCacheCmd(f) + result, err := test.Execute(cmd, "invalidate abc-123 --dry-run", out) + require.NoError(t, err) + + got := result.String() + assert.Contains(t, got, "Dry run: would DELETE /1/agents/abc-123/cache") + assert.NotContains(t, got, "?before=") + assert.Contains(t, got, "all cached completions for this agent") +} + +func Test_runInvalidateCmd_RequiresAgentID(t *testing.T) { + f, out := test.NewFactory(false, nil, nil, "") + cmd := NewCacheCmd(f) + _, err := test.Execute(cmd, "invalidate", out) + require.Error(t, err) +} + +func Test_runInvalidateCmd_NonTTYWithoutConfirmFails(t *testing.T) { + // test.NewFactory(false, ...) configures IO with non-TTY stdin/stdout/stderr. + // CanPrompt() must return false in that case, and the command must + // refuse without --confirm — same contract as `agents delete`. + f, out := test.NewFactory(false, nil, nil, "") + cmd := NewCacheCmd(f) + _, err := test.Execute(cmd, "invalidate abc-123", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "--confirm required") +} + +func Test_runInvalidateCmd_PropagatesAPIError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/missing/cache", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"detail":"Agent not found"}`)) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewCacheCmd(f) + _, err := test.Execute(cmd, "invalidate missing -y", out) + require.Error(t, err) + assert.Contains(t, err.Error(), "Agent not found") +} diff --git a/pkg/cmd/agents/run/run.go b/pkg/cmd/agents/run/run.go index ff437763..777dd951 100644 --- a/pkg/cmd/agents/run/run.go +++ b/pkg/cmd/agents/run/run.go @@ -22,12 +22,16 @@ type RunOptions struct { AgentStudioClient func() (*agentstudio.Client, error) - AgentID string - InputFile string - Message string - NoStream bool - Compatibility string - DryRun bool + AgentID string + InputFile string + Message string + NoStream bool + Compatibility string + NoCache bool + NoMemory bool + NoAnalytics bool + SecureUserToken string + DryRun bool } func NewRunCmd(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command { @@ -80,6 +84,11 @@ func NewRunCmd(f *cmdutil.Factory, runF func(*RunOptions) error) *cobra.Command cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Request a buffered JSON response instead of SSE") cmd.Flags(). StringVar(&opts.Compatibility, "compatibility", "", "Streaming protocol: v4 (ai-sdk-4) or v5 (ai-sdk-5, default)") + cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass the backend completion cache (default: cache enabled)") + cmd.Flags().BoolVar(&opts.NoMemory, "no-memory", false, "Disable agent memory for this completion (default: memory enabled)") + cmd.Flags().BoolVar(&opts.NoAnalytics, "no-analytics", false, "Skip Agent Studio analytics for this completion (default: analytics enabled)") + cmd.Flags(). + StringVar(&opts.SecureUserToken, "secure-user-token", "", "Signed JWT scoping the conversation/memory/analytics partition to an end-user (X-Algolia-Secure-User-Token)") cmd.Flags(). BoolVar(&opts.DryRun, "dry-run", false, "Print the resolved request body without calling the API") @@ -130,8 +139,12 @@ func runRunCmd(opts *RunOptions) error { defer stop() resp, err := client.Completions(ctx, opts.AgentID, body, agentstudio.CompletionOptions{ - Stream: !opts.NoStream, - Compatibility: mode, + Stream: !opts.NoStream, + Compatibility: mode, + NoCache: opts.NoCache, + NoMemory: opts.NoMemory, + NoAnalytics: opts.NoAnalytics, + SecureUserToken: opts.SecureUserToken, }) if err != nil { return err diff --git a/pkg/cmd/agents/run/run_test.go b/pkg/cmd/agents/run/run_test.go index 8939696a..7e245b4b 100644 --- a/pkg/cmd/agents/run/run_test.go +++ b/pkg/cmd/agents/run/run_test.go @@ -87,6 +87,34 @@ func Test_runRunCmd_RequiresAgentID(t *testing.T) { require.Error(t, err) } +func Test_runRunCmd_ForwardsCompletionFlagsToWire(t *testing.T) { + // One end-to-end check that all four Phase 5 flags map onto the + // expected query params + header. Exhaustive matrix lives in + // api/agentstudio/completions_test.go; this exists so a regression + // in the cobra→opts→client wiring (forgetting one field, + // transposing No* polarity, etc.) is caught at the cmd layer. + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/abc-123/completions", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "false", r.URL.Query().Get("cache")) + assert.Equal(t, "false", r.URL.Query().Get("memory")) + assert.Equal(t, "false", r.URL.Query().Get("analytics")) + assert.Equal(t, "ey.signed.jwt", r.Header.Get("X-Algolia-Secure-User-Token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"role":"assistant","content":"ok"}`)) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewRunCmd(f, nil) + _, err := test.Execute(cmd, + "abc-123 -m hi --no-stream --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt", + out) + require.NoError(t, err) +} + func Test_runRunCmd_PropagatesAPIError(t *testing.T) { mux := http.NewServeMux() mux.HandleFunc("/1/agents/missing/completions", func(w http.ResponseWriter, _ *http.Request) { diff --git a/pkg/cmd/agents/try/try.go b/pkg/cmd/agents/try/try.go index 11a56490..c3139fa0 100644 --- a/pkg/cmd/agents/try/try.go +++ b/pkg/cmd/agents/try/try.go @@ -30,11 +30,15 @@ type TryOptions struct { AgentStudioClient func() (*agentstudio.Client, error) - ConfigFile string - InputFile string - Message string - NoStream bool - Compatibility string + ConfigFile string + InputFile string + Message string + NoStream bool + Compatibility string + NoCache bool + NoMemory bool + NoAnalytics bool + SecureUserToken string } func NewTryCmd(f *cmdutil.Factory, runF func(*TryOptions) error) *cobra.Command { @@ -101,6 +105,11 @@ func NewTryCmd(f *cmdutil.Factory, runF func(*TryOptions) error) *cobra.Command cmd.Flags().BoolVar(&opts.NoStream, "no-stream", false, "Request a buffered JSON response instead of SSE") cmd.Flags(). StringVar(&opts.Compatibility, "compatibility", "", "Streaming protocol: v4 (ai-sdk-4) or v5 (ai-sdk-5, default)") + cmd.Flags().BoolVar(&opts.NoCache, "no-cache", false, "Bypass the backend completion cache (default: cache enabled)") + cmd.Flags().BoolVar(&opts.NoMemory, "no-memory", false, "Disable agent memory for this completion (default: memory enabled)") + cmd.Flags().BoolVar(&opts.NoAnalytics, "no-analytics", false, "Skip Agent Studio analytics for this completion (default: analytics enabled)") + cmd.Flags(). + StringVar(&opts.SecureUserToken, "secure-user-token", "", "Signed JWT scoping the conversation/memory/analytics partition to an end-user (X-Algolia-Secure-User-Token)") cmd.MarkFlagsMutuallyExclusive("input", "message") @@ -144,8 +153,12 @@ func runTryCmd(opts *TryOptions) error { defer stop() resp, err := client.Completions(ctx, "test", body, agentstudio.CompletionOptions{ - Stream: !opts.NoStream, - Compatibility: mode, + Stream: !opts.NoStream, + Compatibility: mode, + NoCache: opts.NoCache, + NoMemory: opts.NoMemory, + NoAnalytics: opts.NoAnalytics, + SecureUserToken: opts.SecureUserToken, }) if err != nil { return err diff --git a/pkg/cmd/agents/try/try_test.go b/pkg/cmd/agents/try/try_test.go index 53376c30..c1d292ed 100644 --- a/pkg/cmd/agents/try/try_test.go +++ b/pkg/cmd/agents/try/try_test.go @@ -149,6 +149,37 @@ func Test_runTryCmd_CompatibilityV4(t *testing.T) { assert.Contains(t, result.String(), `"text"`) } +func Test_runTryCmd_ForwardsCompletionFlagsToWire(t *testing.T) { + // One end-to-end check that all four Phase 5 flags map onto the + // expected query params + header. Exhaustive matrix lives in + // api/agentstudio/completions_test.go; this test exists so a + // regression in the cobra→opts→client wiring (forgetting one + // field, transposing No* polarity, etc.) is caught at the cmd + // layer. + mux := http.NewServeMux() + mux.HandleFunc("/1/agents/test/completions", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "false", r.URL.Query().Get("cache")) + assert.Equal(t, "false", r.URL.Query().Get("memory")) + assert.Equal(t, "false", r.URL.Query().Get("analytics")) + assert.Equal(t, "ey.signed.jwt", r.Header.Get("X-Algolia-Secure-User-Token")) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"role":"assistant","content":"ok"}`)) + }) + ts := httptest.NewServer(mux) + t.Cleanup(ts.Close) + + cfgPath := writeTempJSON(t, "cfg.json", `{"model":"x"}`) + + f, out := test.NewFactory(false, nil, nil, "") + f.AgentStudioClient = newClientForServer(t, ts) + + cmd := NewTryCmd(f, nil) + _, err := test.Execute(cmd, + "-c "+cfgPath+" -m hi --no-stream --no-cache --no-memory --no-analytics --secure-user-token ey.signed.jwt", + out) + require.NoError(t, err) +} + func Test_runTryCmd_RejectsInvalidCompatibility(t *testing.T) { cfgPath := writeTempJSON(t, "cfg.json", `{"model":"x"}`) f, out := test.NewFactory(false, nil, nil, "")