From 1eb2e64f033b43dee04bb25537d7a75b8b42442b Mon Sep 17 00:00:00 2001 From: Aaron Perez Date: Wed, 8 Jul 2026 13:37:57 -0500 Subject: [PATCH] Fix workflow list query alias (SKY-12016) (#7206) --- .../tasks/hooks/useRunTagMutations.test.tsx | 106 ++++++++++++++++++ .../routes/tasks/hooks/useRunTagMutations.ts | 79 +++++++++++++ .../tasks/hooks/useRunTagsBatchQuery.test.tsx | 65 +++++++++++ .../tasks/hooks/useRunTagsBatchQuery.ts | 53 +++++++++ .../tasks/hooks/useRunTagsQuery.test.tsx | 48 ++++++++ .../src/routes/tasks/hooks/useRunTagsQuery.ts | 32 ++++++ .../hooks/useTagValuesQuery.test.tsx | 67 +++++++++++ .../workflows/hooks/useTagValuesQuery.ts | 47 ++++++-- .../src/routes/workflows/types/tagTypes.ts | 9 ++ skyvern/cli/mcp_tools/workflow.py | 22 +++- skyvern/cli/workflow.py | 1 + tests/unit/test_cli_commands.py | 30 +++++ tests/unit/test_mcp_workflow_list_drift.py | 27 +++++ 13 files changed, 574 insertions(+), 12 deletions(-) create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.test.tsx create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.ts create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.test.tsx create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.ts create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.test.tsx create mode 100644 skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.ts create mode 100644 skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.test.tsx diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.test.tsx b/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.test.tsx new file mode 100644 index 000000000..fc11507fa --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.test.tsx @@ -0,0 +1,106 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockDelete, mockPost } = vi.hoisted(() => ({ + mockDelete: vi.fn(), + mockPost: vi.fn(), +})); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ delete: mockDelete, post: mockPost }), +})); + +vi.mock("@/components/ui/use-toast", () => ({ toast: vi.fn() })); + +import { + useApplyRunTagsMutation, + useDeleteRunTagMutation, +} from "./useRunTagMutations"; + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + mutations: { retry: false }, + }, + }); + const invalidateQueries = vi.spyOn(queryClient, "invalidateQueries"); + return { + invalidateQueries, + wrapper({ children }: { children: ReactNode }) { + return ( + + {children} + + ); + }, + }; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useRunTagMutations", () => { + it("posts tag changes to the run tag endpoint", async () => { + mockPost.mockResolvedValue({ data: { workflow_run_id: "wr_1", tags: [] } }); + const { invalidateQueries, wrapper } = createWrapper(); + const { result } = renderHook(() => useApplyRunTagsMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + workflowRunId: "wr_1", + data: { + tags: [{ key: "env", value: "prod" }], + tags_to_delete: [{ value: "old" }], + colors: { env: "green" }, + }, + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockPost).toHaveBeenCalledWith("/runs/wr_1/tags", { + tags: [{ key: "env", value: "prod" }], + tags_to_delete: [{ value: "old" }], + colors: { env: "green" }, + }); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["run-tags"], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["runs"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["tasks"] }); + }); + + it("deletes a grouped tag by key from the run tag endpoint", async () => { + mockDelete.mockResolvedValue({ data: {} }); + const { invalidateQueries, wrapper } = createWrapper(); + const { result } = renderHook(() => useDeleteRunTagMutation(), { + wrapper, + }); + + act(() => { + result.current.mutate({ + workflowRunId: "wr_1", + key: "customer/tier", + }); + }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockDelete).toHaveBeenCalledWith("/runs/wr_1/tags/customer%2Ftier"); + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ["run-tags"], + }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["runs"] }); + expect(invalidateQueries).toHaveBeenCalledWith({ queryKey: ["tasks"] }); + }); +}); diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.ts b/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.ts new file mode 100644 index 000000000..0b77face9 --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagMutations.ts @@ -0,0 +1,79 @@ +import { getClient } from "@/api/AxiosClient"; +import { toast } from "@/components/ui/use-toast"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { tagErrorMessage } from "@/routes/workflows/hooks/useWorkflowTagMutations"; +import type { + RunTagsResponse, + TagApplyRequest, +} from "@/routes/workflows/types/tagTypes"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; + +function useApplyRunTagsMutation() { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + workflowRunId, + data, + }: { + workflowRunId: string; + data: TagApplyRequest; + }) => { + const client = await getClient(credentialGetter); + return client + .post(`/runs/${workflowRunId}/tags`, data) + .then((response) => response.data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["run-tags"] }); + queryClient.invalidateQueries({ queryKey: ["tag-keys"] }); + queryClient.invalidateQueries({ queryKey: ["tag-values"] }); + queryClient.invalidateQueries({ queryKey: ["runs"] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Failed to update run tags", + description: tagErrorMessage(error), + }); + }, + }); +} + +function useDeleteRunTagMutation() { + const credentialGetter = useCredentialGetter(); + const queryClient = useQueryClient(); + + return useMutation({ + mutationFn: async ({ + workflowRunId, + key, + }: { + workflowRunId: string; + key: string; + }) => { + const client = await getClient(credentialGetter); + return client + .delete(`/runs/${workflowRunId}/tags/${encodeURIComponent(key)}`) + .then((response) => response.data); + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["run-tags"] }); + queryClient.invalidateQueries({ queryKey: ["tag-keys"] }); + queryClient.invalidateQueries({ queryKey: ["tag-values"] }); + queryClient.invalidateQueries({ queryKey: ["runs"] }); + queryClient.invalidateQueries({ queryKey: ["tasks"] }); + }, + onError: (error: unknown) => { + toast({ + variant: "destructive", + title: "Failed to delete run tag", + description: tagErrorMessage(error), + }); + }, + }); +} + +export { useApplyRunTagsMutation, useDeleteRunTagMutation }; diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.test.tsx b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.test.tsx new file mode 100644 index 000000000..a329cd2b4 --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ get: mockGet }), +})); + +import { useRunTagsBatchQuery } from "./useRunTagsBatchQuery"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +function lastParams(): URLSearchParams { + const call = mockGet.mock.calls[0]; + if (!call) { + throw new Error("client.get was not called"); + } + return (call[1] as { params: URLSearchParams }).params; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useRunTagsBatchQuery", () => { + it("fetches run tags from the run batch endpoint with sorted ids", async () => { + mockGet.mockResolvedValue({ + data: { + run_tags: { + wr_1: [{ key: "env", value: "prod" }], + wr_2: [], + }, + }, + }); + + const { result } = renderHook( + () => useRunTagsBatchQuery(["wr_2", "wr_1"]), + { wrapper }, + ); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGet).toHaveBeenCalledWith("/run-tags", expect.anything()); + expect(lastParams().get("workflow_run_ids")).toBe("wr_1,wr_2"); + expect(result.current.data).toEqual({ + wr_1: [{ key: "env", value: "prod" }], + wr_2: [], + }); + }); +}); diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.ts b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.ts new file mode 100644 index 000000000..5dbd6378b --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsBatchQuery.ts @@ -0,0 +1,53 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { + normalizeWorkflowTags, + type RunTagsBatchResponse, + type Tag, +} from "@/routes/workflows/types/tagTypes"; + +const BATCH_RUN_TAGS_MAX_IDS = 200; + +function useRunTagsBatchQuery( + workflowRunIds: Array, + { enabled = true }: { enabled?: boolean } = {}, +) { + const credentialGetter = useCredentialGetter(); + const sortedIds = [...workflowRunIds].sort(); + + return useQuery({ + queryKey: ["run-tags", "batch", sortedIds], + enabled: enabled && sortedIds.length > 0, + queryFn: async () => { + const client = await getClient(credentialGetter); + const chunks: Array> = []; + for (let i = 0; i < sortedIds.length; i += BATCH_RUN_TAGS_MAX_IDS) { + chunks.push(sortedIds.slice(i, i + BATCH_RUN_TAGS_MAX_IDS)); + } + + const responses = await Promise.all( + chunks.map((ids) => { + const params = new URLSearchParams(); + params.append("workflow_run_ids", ids.join(",")); + return client + .get("/run-tags", { params }) + .then((response) => response.data.run_tags); + }), + ); + + const merged: Record> = {}; + for (const response of responses) { + if (response === null || typeof response !== "object") { + continue; + } + for (const [workflowRunId, tags] of Object.entries(response)) { + merged[workflowRunId] = normalizeWorkflowTags(tags); + } + } + return merged; + }, + }); +} + +export { useRunTagsBatchQuery }; diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.test.tsx b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.test.tsx new file mode 100644 index 000000000..d8292ff66 --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.test.tsx @@ -0,0 +1,48 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ get: mockGet }), +})); + +import { useRunTagsQuery } from "./useRunTagsQuery"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useRunTagsQuery", () => { + it("fetches current tags for one workflow run", async () => { + mockGet.mockResolvedValue({ + data: { + workflow_run_id: "wr_1", + tags: [{ key: null, value: "manual", source: "manual" }], + }, + }); + + const { result } = renderHook(() => useRunTagsQuery("wr_1"), { wrapper }); + + await waitFor(() => expect(result.current.isSuccess).toBe(true)); + expect(mockGet).toHaveBeenCalledWith("/runs/wr_1/tags"); + expect(result.current.data).toEqual([{ key: null, value: "manual" }]); + }); +}); diff --git a/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.ts b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.ts new file mode 100644 index 000000000..f3e0beffb --- /dev/null +++ b/skyvern-frontend/src/routes/tasks/hooks/useRunTagsQuery.ts @@ -0,0 +1,32 @@ +import { getClient } from "@/api/AxiosClient"; +import { useCredentialGetter } from "@/hooks/useCredentialGetter"; +import { useQuery } from "@tanstack/react-query"; +import { + normalizeWorkflowTags, + type RunTagsResponse, +} from "@/routes/workflows/types/tagTypes"; + +function useRunTagsQuery( + workflowRunId: string | null | undefined, + { enabled = true }: { enabled?: boolean } = {}, +) { + const credentialGetter = useCredentialGetter(); + + return useQuery({ + queryKey: ["run-tags", workflowRunId], + enabled: enabled && !!workflowRunId, + queryFn: async () => { + const client = await getClient(credentialGetter); + return client + .get(`/runs/${workflowRunId}/tags`) + .then((response) => + normalizeWorkflowTags(response.data.tags).map((tag) => ({ + key: tag.key, + value: tag.value, + })), + ); + }, + }); +} + +export { useRunTagsQuery }; diff --git a/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.test.tsx b/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.test.tsx new file mode 100644 index 000000000..74a69d215 --- /dev/null +++ b/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.test.tsx @@ -0,0 +1,67 @@ +// @vitest-environment jsdom + +import type { ReactNode } from "react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { renderHook, waitFor } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; + +const { mockGet } = vi.hoisted(() => ({ mockGet: vi.fn() })); + +vi.mock("@/hooks/useCredentialGetter", () => ({ + useCredentialGetter: () => () => Promise.resolve("test-token"), +})); + +vi.mock("@/api/AxiosClient", () => ({ + getClient: () => Promise.resolve({ get: mockGet }), +})); + +import { useTagValuesListQuery, useTagValuesQuery } from "./useTagValuesQuery"; + +function wrapper({ children }: { children: ReactNode }) { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return ( + {children} + ); +} + +function lastParams(): URLSearchParams { + const call = mockGet.mock.calls[0]; + if (!call) { + throw new Error("client.get was not called"); + } + return (call[1] as { params: URLSearchParams }).params; +} + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("useTagValuesQuery", () => { + it("passes key when fetching the color map for a specific tag group", async () => { + mockGet.mockResolvedValue({ + data: [{ key: "env", value: "prod", color: "green" }], + }); + + renderHook(() => useTagValuesQuery({ key: "env" }), { wrapper }); + + await waitFor(() => expect(mockGet).toHaveBeenCalled()); + expect(mockGet).toHaveBeenCalledWith("/tag-values", expect.anything()); + expect(lastParams().get("key")).toBe("env"); + }); +}); + +describe("useTagValuesListQuery", () => { + it("passes key when fetching raw values for a specific tag group", async () => { + mockGet.mockResolvedValue({ + data: [{ key: "team", value: "ops", color: "blue" }], + }); + + renderHook(() => useTagValuesListQuery({ key: "team" }), { wrapper }); + + await waitFor(() => expect(mockGet).toHaveBeenCalled()); + expect(mockGet).toHaveBeenCalledWith("/tag-values", expect.anything()); + expect(lastParams().get("key")).toBe("team"); + }); +}); diff --git a/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.ts b/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.ts index 6b958d6b4..aca2b9f88 100644 --- a/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.ts +++ b/skyvern-frontend/src/routes/workflows/hooks/useTagValuesQuery.ts @@ -4,20 +4,42 @@ import { useQuery } from "@tanstack/react-query"; import type { TagValue } from "../types/tagTypes"; import { buildTagColorMap, type TagColorMap } from "../types/tagColors"; +type TagValuesQueryOptions = { + enabled?: boolean; + key?: string | null; +}; + +function tagValuesQueryOptions(key: string | null | undefined) { + const trimmedKey = key?.trim(); + const params = new URLSearchParams(); + if (trimmedKey) { + params.append("key", trimmedKey); + } + return { + queryKey: trimmedKey ? ["tag-values", { key: trimmedKey }] : ["tag-values"], + requestConfig: trimmedKey ? { params } : undefined, + }; +} + // Per-org grouped-tag color registry. Returns a (key, value) -> palette color Map // (built in `select`, so the transform is memoized and the map reference is stable // across renders). Mirrors useTagKeysQuery's join of the key registry. -function useTagValuesQuery({ enabled = true }: { enabled?: boolean } = {}) { +function useTagValuesQuery({ + enabled = true, + key = null, +}: TagValuesQueryOptions = {}) { const credentialGetter = useCredentialGetter(); + const { queryKey, requestConfig } = tagValuesQueryOptions(key); return useQuery, Error, TagColorMap>({ - queryKey: ["tag-values"], + queryKey, enabled, queryFn: async () => { const client = await getClient(credentialGetter); - return client - .get>("/tag-values") - .then((response) => response.data); + const request = requestConfig + ? client.get>("/tag-values", requestConfig) + : client.get>("/tag-values"); + return request.then((response) => response.data); }, select: buildTagColorMap, }); @@ -26,20 +48,25 @@ function useTagValuesQuery({ enabled = true }: { enabled?: boolean } = {}) { // Same source/cache as useTagValuesQuery but returns the raw rows (with // workflow_count) for the label-management surface, which needs usage counts and // per-(key,value) actions rather than just the color lookup map. -function useTagValuesListQuery({ enabled = true }: { enabled?: boolean } = {}) { +function useTagValuesListQuery({ + enabled = true, + key = null, +}: TagValuesQueryOptions = {}) { const credentialGetter = useCredentialGetter(); + const { queryKey, requestConfig } = tagValuesQueryOptions(key); return useQuery>({ - queryKey: ["tag-values"], + queryKey, enabled, // This queryFn intentionally duplicates useTagValuesQuery's so the shared // "tag-values" key still has a fetcher when only this hook is mounted (e.g. // direct nav to /settings/labels); removing it yields a Missing queryFn error. queryFn: async () => { const client = await getClient(credentialGetter); - return client - .get>("/tag-values") - .then((response) => response.data); + const request = requestConfig + ? client.get>("/tag-values", requestConfig) + : client.get>("/tag-values"); + return request.then((response) => response.data); }, }); } diff --git a/skyvern-frontend/src/routes/workflows/types/tagTypes.ts b/skyvern-frontend/src/routes/workflows/types/tagTypes.ts index 7435b0a7a..822c3a3b0 100644 --- a/skyvern-frontend/src/routes/workflows/types/tagTypes.ts +++ b/skyvern-frontend/src/routes/workflows/types/tagTypes.ts @@ -20,6 +20,11 @@ export interface TagsResponse { tags: Array; } +export interface RunTagsResponse { + workflow_run_id: string; + tags: Array; +} + export interface TagKey { key: string; description: string | null; @@ -46,6 +51,10 @@ export interface WorkflowTagsBatchResponse { workflow_tags: Record>; } +export interface RunTagsBatchResponse { + run_tags: Record>; +} + // Body for POST /workflows/{wpid}/tags. `tags` sets/overwrites ({key?, value}); // `tags_to_delete` removes a grouped tag by {key} or a label by {value}. export interface TagInput { diff --git a/skyvern/cli/mcp_tools/workflow.py b/skyvern/cli/mcp_tools/workflow.py index 516c9b978..b75bc7314 100644 --- a/skyvern/cli/mcp_tools/workflow.py +++ b/skyvern/cli/mcp_tools/workflow.py @@ -1180,16 +1180,32 @@ def _parse_definition(definition: str, fmt: str) -> tuple[dict[str, Any] | None, async def skyvern_workflow_list( search: Annotated[str | None, "Search across workflow titles, folder names, and parameter metadata"] = None, + query: Annotated[ + str | None, + "Deprecated alias for search. Use search for new calls.", + ] = None, page: Annotated[int, Field(description="Page number (1-based)", ge=1)] = 1, page_size: Annotated[int, Field(description="Results per page", ge=1, le=100)] = 10, only_workflows: Annotated[bool, "Only return multi-step workflows (exclude saved tasks)"] = False, ) -> dict[str, Any]: """Find and browse available Skyvern workflows. Use when you need to discover what workflows exist, search for a workflow by name, or list all workflows for an organization.""" + if search is not None and query is not None and search != query: + return make_result( + "skyvern_workflow_list", + ok=False, + error=make_error( + ErrorCode.INVALID_INPUT, + "Provide either search or query, not conflicting values", + "Use search for new calls; query is a compatibility alias.", + ), + ) + + effective_search = search if search is not None else query with Timer() as timer: try: workflows = await list_workflows_raw( - search=search, + search=effective_search, page=page, page_size=page_size, only_workflows=only_workflows, @@ -1211,7 +1227,9 @@ async def skyvern_workflow_list( "page_size": page_size, "count": len(workflows), "has_more": len(workflows) == page_size, - "sdk_equivalent": f"await skyvern.get_workflows(search_key={search!r}, page={page}, page_size={page_size})", + "sdk_equivalent": ( + f"await skyvern.get_workflows(search_key={effective_search!r}, page={page}, page_size={page_size})" + ), }, timing_ms=timer.timing_ms, ) diff --git a/skyvern/cli/workflow.py b/skyvern/cli/workflow.py index eda6a8330..451c142f6 100644 --- a/skyvern/cli/workflow.py +++ b/skyvern/cli/workflow.py @@ -97,6 +97,7 @@ def workflow_list( search: str | None = typer.Option( None, "--search", + "--query", help="Search across workflow titles, folder names, and parameter metadata.", ), page: int = typer.Option(1, "--page", min=1, help="Page number (1-based)."), diff --git a/tests/unit/test_cli_commands.py b/tests/unit/test_cli_commands.py index 4a5e39d71..503324853 100644 --- a/tests/unit/test_cli_commands.py +++ b/tests/unit/test_cli_commands.py @@ -335,6 +335,36 @@ class TestBrowserCommands: class TestWorkflowCommands: + def test_workflow_list_query_alias_maps_to_search(self, monkeypatch: pytest.MonkeyPatch) -> None: + from skyvern.cli import workflow as workflow_cmd + + monkeypatch.setattr(workflow_cmd, "prepare_cli_runtime", lambda **_: None) + tool = AsyncMock( + return_value={ + "ok": True, + "action": "skyvern_workflow_list", + "browser_context": {"mode": "none", "session_id": None, "cdp_url": None}, + "data": {"workflows": [], "page": 1, "page_size": 10, "count": 0, "has_more": False}, + "artifacts": [], + "timing_ms": {}, + "warnings": [], + "error": None, + } + ) + monkeypatch.setattr(workflow_cmd, "tool_workflow_list", tool) + + result = CliRunner().invoke(workflow_cmd.workflow_app, ["list", "--query", "invoice", "--json"]) + + assert result.exit_code == 0, result.output + assert tool.await_args.kwargs == { + "search": "invoice", + "page": 1, + "page_size": 10, + "only_workflows": False, + } + parsed = json.loads(result.output) + assert parsed["ok"] is True + def test_workflow_get_outputs_mcp_envelope_in_json_mode( self, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture ) -> None: diff --git a/tests/unit/test_mcp_workflow_list_drift.py b/tests/unit/test_mcp_workflow_list_drift.py index f38fc2f21..18743ecac 100644 --- a/tests/unit/test_mcp_workflow_list_drift.py +++ b/tests/unit/test_mcp_workflow_list_drift.py @@ -136,6 +136,33 @@ async def test_list_includes_search_key_only_when_search_is_present(monkeypatch: } +@pytest.mark.asyncio +async def test_list_accepts_query_alias_for_search(monkeypatch: pytest.MonkeyPatch) -> None: + request_mock = _patch_skyvern_list_response(monkeypatch, payload=[]) + + result = await mcp_workflow.skyvern_workflow_list(query="invoice", page=2, page_size=5) + + assert result["ok"] is True, result + params = request_mock.await_args.kwargs["params"] + assert params == { + "search_key": "invoice", + "page": 2, + "page_size": 5, + "only_workflows": False, + } + + +@pytest.mark.asyncio +async def test_list_rejects_conflicting_search_and_query(monkeypatch: pytest.MonkeyPatch) -> None: + request_mock = _patch_skyvern_list_response(monkeypatch, payload=[]) + + result = await mcp_workflow.skyvern_workflow_list(search="invoice", query="receipt") + + assert result["ok"] is False + assert result["error"]["code"] == "INVALID_INPUT" + request_mock.assert_not_awaited() + + @pytest.mark.asyncio async def test_raw_list_uses_fern_http_wrapper_auth_headers(monkeypatch: pytest.MonkeyPatch) -> None: """The raw bypass still uses Fern's HTTP wrapper, including auth and query encoding."""