diff --git a/ai_evals/adapters/frontend/mockBackend.ts b/ai_evals/adapters/frontend/mockBackend.ts index f9023449a2..2ab8f47f37 100644 --- a/ai_evals/adapters/frontend/mockBackend.ts +++ b/ai_evals/adapters/frontend/mockBackend.ts @@ -10,6 +10,7 @@ import type { import type { DataTableTables, DataTableTableSchema, + EndpointTool, GetDraftForUserResponse, GetOwnDraftResponse, ListDraftsResponse, @@ -694,3 +695,138 @@ function buildBenchmarkApp(app: BenchmarkWorkspaceApp): AppWithLastVersion { raw_app: true } } + +// ============= API endpoint catalog (McpService.listMcpTools + raw fetch) ============= +// The global chat's API catalog tools list endpoints via McpService and execute +// them with a plain relative fetch('/api/...'), which has no meaning in the +// vitest environment. A representative slice of the real catalog is served here, +// and `handleBenchmarkApiFetch` answers the executed calls. + +const BENCHMARK_MCP_TOOLS: EndpointTool[] = [ + { + name: 'listWorkers', + description: 'List workers', + instructions: 'List all workers with their last ping and job counts.', + path: '/workers/list', + method: 'GET', + query_params_schema: { + type: 'object', + properties: { page: { type: 'integer' }, per_page: { type: 'integer' } } + } + }, + { + name: 'listQueue', + description: 'List queued jobs', + instructions: '', + path: '/w/{workspace}/jobs/queue/list', + method: 'GET', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' } }, + required: ['workspace'] + } + }, + { + name: 'runScriptByPath', + description: 'Run the deployed version of a script by path', + instructions: '', + path: '/w/{workspace}/jobs/run/p/{path}', + method: 'POST', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, path: { type: 'string' } }, + required: ['workspace', 'path'] + }, + body_schema: { type: 'object', properties: {} } + }, + { + name: 'runFlowByPath', + description: 'Run the deployed version of a flow by path', + instructions: '', + path: '/w/{workspace}/jobs/run/f/{path}', + method: 'POST', + path_params_schema: { + type: 'object', + properties: { workspace: { type: 'string' }, path: { type: 'string' } }, + required: ['workspace', 'path'] + }, + body_schema: { type: 'object', properties: {} } + }, + // Draft-covered endpoints, present so steering cases exercise the guard the + // way production does (hidden from search, refused at call time). + { + name: 'getScriptByPath', + description: 'Get a script by path', + instructions: '', + path: '/w/{workspace}/scripts/get/p/{path}', + method: 'GET' + }, + { + name: 'createFlow', + description: 'Create a flow', + instructions: '', + path: '/w/{workspace}/flows/create', + method: 'POST' + }, + { + name: 'deleteSchedule', + description: 'Delete a schedule', + instructions: '', + path: '/w/{workspace}/schedules/delete/{path}', + method: 'DELETE' + }, + { + name: 'getVariable', + description: 'Get a variable', + instructions: '', + path: '/w/{workspace}/variables/get/{path}', + method: 'GET' + } +] + +export function listBenchmarkMcpTools(): EndpointTool[] { + return BENCHMARK_MCP_TOOLS +} + +const BENCHMARK_WORKERS = [ + { + worker: 'wk-benchmark-1', + worker_instance: 'benchmark-host', + last_ping: 2, + started_at: BENCHMARK_TIMESTAMP, + jobs_executed: 42, + custom_tags: null, + worker_group: 'default', + wm_version: 'benchmark' + }, + { + worker: 'wk-benchmark-2', + worker_instance: 'benchmark-host', + last_ping: 5, + started_at: BENCHMARK_TIMESTAMP, + jobs_executed: 17, + custom_tags: null, + worker_group: 'default', + wm_version: 'benchmark' + } +] + +/** True when `handleBenchmarkApiFetch` has an answer for this `/api/...` url. + * Any other relative fetch must keep its normal (non-benchmark) behavior — + * intercepting it with a synthetic 404 sends the model into retry loops. */ +export function hasBenchmarkApiHandler(url: string): boolean { + const path = url.split('?')[0] + return path === '/api/workers/list' || /^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path) +} + +/** Answer a relative `/api/...` fetch issued by the API catalog executor. */ +export function handleBenchmarkApiFetch(url: string): Response { + const path = url.split('?')[0] + if (path === '/api/workers/list') { + return Response.json(BENCHMARK_WORKERS) + } + if (/^\/api\/w\/[^/]+\/jobs\/queue\/list$/.test(path)) { + return Response.json([]) + } + return Response.json({ error: `no benchmark handler for ${path}` }, { status: 404 }) +} diff --git a/ai_evals/adapters/frontend/vitestAdapter.test.ts b/ai_evals/adapters/frontend/vitestAdapter.test.ts index 92e33414df..1532928bff 100644 --- a/ai_evals/adapters/frontend/vitestAdapter.test.ts +++ b/ai_evals/adapters/frontend/vitestAdapter.test.ts @@ -3,6 +3,20 @@ import { expect, it, vi } from 'vitest' import { mkdir, writeFile } from 'fs/promises' // @ts-ignore - Node.js path import { dirname, resolve } from 'path' +import { handleBenchmarkApiFetch, hasBenchmarkApiHandler } from './mockBackend' + +// The API catalog executor issues relative fetch('/api/...') calls, which have +// no meaning in the vitest environment — serve the ones the benchmark handles. +// Every other relative fetch keeps its normal behavior (it fails the same way +// it does without this stub) so unrelated tools see an unchanged environment. +const ORIGINAL_FETCH = globalThis.fetch +globalThis.fetch = (async (input: unknown, init?: RequestInit) => { + const url = typeof input === 'string' ? input : ((input as Request | URL | null)?.url ?? '') + if (typeof url === 'string' && hasBenchmarkApiHandler(url)) { + return handleBenchmarkApiFetch(url) + } + return ORIGINAL_FETCH(input as Parameters[0], init) +}) as typeof fetch vi.mock('monaco-editor', () => ({ editor: {}, @@ -57,7 +71,8 @@ vi.mock('$lib/gen', async () => { runBenchmarkDatatableSql, runBenchmarkFlowByPath, runBenchmarkScriptPreview, - updateBenchmarkDraft + updateBenchmarkDraft, + listBenchmarkMcpTools } = await import('./mockBackend') function wrapService(target: T, overrides: Record): T { @@ -299,6 +314,12 @@ vi.mock('$lib/gen', async () => { queryResourceTypes: async (data: { workspace: string }) => hasBenchmarkWorkspace(data.workspace) ? [] : actual.ResourceService.queryResourceTypes(data) }), + McpService: wrapService(actual.McpService, { + listMcpTools: async (data: { workspace: string }) => + hasBenchmarkWorkspace(data.workspace) + ? listBenchmarkMcpTools() + : actual.McpService.listMcpTools(data) + }), VariableService: wrapService(actual.VariableService, { existsVariable: async (data: { workspace: string; path: string }) => hasBenchmarkWorkspace(data.workspace) ? false : actual.VariableService.existsVariable(data), diff --git a/ai_evals/cases/global.yaml b/ai_evals/cases/global.yaml index 6781fb90b5..646a9e870d 100644 --- a/ai_evals/cases/global.yaml +++ b/ai_evals/cases/global.yaml @@ -1561,3 +1561,86 @@ - fetches the schema through get_db_schema with the resource path f/data/reports_pg - when the lookup fails, tells the user instead of inventing table names - does not write scripts or resources to answer a read-only question + +# --- API catalog (search_api_endpoints / call_api_get / call_api_endpoint) --- +# The harness serves the catalog and the executed calls itself (mock +# listMcpTools + benchmark fetch handlers in adapters/frontend), so these cases +# do not require an mcp-enabled eval backend. + +- id: global-test30-api-catalog-workers + prompt: |- + Are any workers online right now? Give me a quick summary of the workers and when they last pinged. + initial: ai_evals/fixtures/frontend/global/initial/user_admin_empty.json + runtime: + maxTurns: 8 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - search_api_endpoints + - call_api_get + forbiddenToolsUsed: + - call_api_endpoint + - write_script + - deploy_workspace_item + toolCallArgs: + - tool: call_api_get + field: name + stringIncludesAnyOf: + - listWorkers + # Read-only workspace inspection produces no draft; validate via tool use. + skipJudge: true + judgeChecklist: + - discovers the workers endpoint through the API catalog instead of guessing or fabricating + - reports worker status from the returned data + +- id: global-test31-draft-test-run-not-deployed + prompt: |- + Update `f/evals/global/format_greeting` so the provided name is uppercased in the greeting, then run it with name "ada" to check it works. + Keep it as an AI draft only; do not deploy or save it. + initial: ai_evals/fixtures/frontend/global/initial/format_greeting_script.json + runtime: + maxTurns: 10 + validate: + draftCountExactly: 1 + requiredDrafts: + - type: script + path: f/evals/global/format_greeting + language: bun + valueIncludes: + - toUpperCase + toolExpect: + requiredToolsUsed: + - test_run_script + forbiddenToolsUsed: + - call_api_endpoint + - deploy_workspace_item + - delete_workspace_item + # The judge only sees the drafts artifact and cannot observe runs, so it always + # docks the prompt's "run it" requirement — validate deterministically instead: + # draft content via valueIncludes, the test run via toolExpect (test_run_script + # required, call_api_endpoint forbidden). + skipJudge: true + judgeChecklist: + - creates an AI draft of f/evals/global/format_greeting with the name uppercased in the greeting + - does not deploy or save the draft + +- id: global-test32-delete-via-dedicated-tool + prompt: |- + Delete the deployed script `f/evals/global/send_report_digest` from the workspace — we don't use it anymore. + initial: ai_evals/fixtures/frontend/global/initial/report_digest_script.json + runtime: + maxTurns: 6 + validate: + draftCountExactly: 0 + toolExpect: + requiredToolsUsed: + - delete_workspace_item + forbiddenToolsUsed: + - call_api_endpoint + - search_api_endpoints + - write_script + # Deletion produces no draft; validate via tool use. + skipJudge: true + judgeChecklist: + - deletes the deployed script via delete_workspace_item rather than a raw API endpoint diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts index 470b396b53..8158fe62b8 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.test.ts @@ -69,6 +69,13 @@ const CATALOG = [ path: '/w/{workspace}/variables/get/{path}', method: 'GET' }, + { + name: 'getScriptByPath', + description: 'Get script by path', + instructions: '', + path: '/w/{workspace}/scripts/get/p/{path}', + method: 'GET' + }, { name: 'deleteScriptByHash', description: 'Delete a script by hash', @@ -171,6 +178,22 @@ describe('call_api_get', () => { expect(search.matches.map((m: any) => m.name)).not.toContain('deleteScriptByHash') }) + it('refuses draft-blind item reads and lists, pointing at the draft-aware tools', async () => { + for (const name of ['getScriptByPath', 'getResource', 'getSchedule']) { + const result = await run('call_api_get', { name }) + expect(result.success).toBe(false) + expect(result.error).toContain('read_workspace_item') + } + for (const name of ['listScripts', 'listFlows', 'listResource', 'listSchedules']) { + const result = await run('call_api_get', { name }) + expect(result.success).toBe(false) + expect(result.error).toContain('list_workspace_items') + } + + const search = await run('search_api_endpoints', { query: 'get script' }) + expect(search.matches.map((m: any) => m.name)).not.toContain('getScriptByPath') + }) + it('refuses variable reads so variable values never reach the model', async () => { const result = await run('call_api_get', { name: 'getVariable' }) expect(result.success).toBe(false) diff --git a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts index be4ca892d8..db0b431967 100644 --- a/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts +++ b/frontend/src/lib/components/copilot/chat/global/apiCatalogTools.ts @@ -17,12 +17,21 @@ import { createToolDef, type Tool } from '../shared' // explicit deploy, draft cleanup on delete), the variable reads would expose // variable values to the model (getVariable even decrypts secrets by default), // and the rest are exact duplicates that would fragment behavior across two -// code paths. getResource stays available: resource values are readable in -// this chat by design (read_workspace_item returns them too) — secrets belong -// in variables referenced as "$var:path", which a plain get leaves unresolved. +// code paths. const COVERED_ENDPOINTS: Record = { getVariable: 'read_workspace_item (variable values are never readable in chat)', listVariable: 'list_workspace_items (variable values are never readable in chat)', + // The item read/list endpoints return deployed state only, blind to the user's + // drafts; read_workspace_item / list_workspace_items merge drafts, and for + // flows return the compact JSON that patch_flow_json matches against. + getScriptByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', + getFlowByPath: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', + getResource: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', + getSchedule: 'read_workspace_item (reads your draft when one exists; pass version: "deployed" for the deployed state)', + listScripts: 'list_workspace_items (it includes your drafts)', + listFlows: 'list_workspace_items (it includes your drafts)', + listResource: 'list_workspace_items (it includes your drafts)', + listSchedules: 'list_workspace_items (it includes your drafts)', deleteScriptByPath: 'delete_workspace_item', deleteScriptByHash: 'delete_workspace_item', deleteFlowByPath: 'delete_workspace_item', diff --git a/frontend/src/lib/components/copilot/chat/global/core.test.ts b/frontend/src/lib/components/copilot/chat/global/core.test.ts index 78d22e3fc8..595eaaa15b 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.test.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.test.ts @@ -492,6 +492,42 @@ describe('global AI tools', () => { ]) }) + it('reads the deployed state, skipping chat and DB drafts, with version: deployed', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/greet', + language: 'bun', + content: 'export async function main(renamed_input: string) {}' + }) + vi.mocked(ScriptService.getScriptByPath).mockResolvedValueOnce({ + hash: 1, + path: 'f/scripts/greet', + summary: 'Deployed greet', + content: 'export async function main(name: string) {}', + schema: { properties: { name: { type: 'string' } } }, + language: 'bun', + kind: 'script', + draft: { content: 'export async function main(db_draft_input: string) {}' } + } as any) + + const raw = await callGlobalTool('read_workspace_item', { + type: 'script', + path: 'f/scripts/greet', + version: 'deployed' + }) + const item = JSON.parse(raw) + + expect(ScriptService.getScriptByPath).toHaveBeenCalledWith({ + workspace: WORKSPACE, + path: 'f/scripts/greet', + getDraft: false + }) + expect(item.isDraft).toBe(false) + expect(item.schema).toEqual({ properties: { name: { type: 'string' } } }) + expect(raw).toContain('main(name: string)') + expect(raw).not.toContain('renamed_input') + expect(raw).not.toContain('db_draft_input') + }) + it('redacts variable draft values when reading workspace items', async () => { await callGlobalTool('write_variable', { path: 'f/secrets/api_key', @@ -874,6 +910,59 @@ describe('global AI tools', () => { ]) }) + it('forwards page to the list calls, capping page-1 drafts at limit per type', async () => { + await callGlobalTool('write_script', { + path: 'f/scripts/draft_a', + language: 'bun', + content: 'export async function main() {}' + }) + await callGlobalTool('write_script', { + path: 'f/scripts/draft_b', + language: 'bun', + content: 'export async function main() {}' + }) + + const page1 = await callGlobalTool('list_workspace_items', { + types: ['script'], + limit: 1, + page: 1 + }) + const page2 = await callGlobalTool('list_workspace_items', { + types: ['script'], + limit: 1, + page: 2 + }) + + expect(ScriptService.listScripts).toHaveBeenCalledWith(expect.objectContaining({ page: 2 })) + // Bounded on page 1, no draft rows on later pages; the capped-out draft + // stays reachable through the query filter. + expect(JSON.parse(page1)).toHaveLength(1) + expect(JSON.parse(page2)).toEqual([]) + const byQuery = await callGlobalTool('list_workspace_items', { + types: ['script'], + query: 'draft_b' + }) + expect(JSON.parse(byQuery).map((i: any) => i.path)).toEqual(['f/scripts/draft_b']) + }) + + it('applies limit per item type so a full page of one type cannot hide another', async () => { + vi.mocked(ScriptService.listScripts).mockResolvedValueOnce([ + { path: 'f/scripts/s1', language: 'bun' }, + { path: 'f/scripts/s2', language: 'bun', draft_only: true } + ] as any) + vi.mocked(FlowService.listFlows).mockResolvedValueOnce([{ path: 'f/flows/f1' }] as any) + + const raw = await callGlobalTool('list_workspace_items', { + types: ['script', 'flow'], + limit: 2 + }) + + const items = JSON.parse(raw) + expect(items.map((i: any) => i.path)).toEqual(['f/scripts/s1', 'f/scripts/s2', 'f/flows/f1']) + // Server-synthesized draft-only rows must read as drafts, not deployed items. + expect(items.map((i: any) => i.isDraft)).toEqual([false, true, false]) + }) + it('lists and edits the live script editor draft through its effective path', async () => { seedBackendDraft( 'script', diff --git a/frontend/src/lib/components/copilot/chat/global/core.ts b/frontend/src/lib/components/copilot/chat/global/core.ts index 49373af584..fdabb15bc7 100644 --- a/frontend/src/lib/components/copilot/chat/global/core.ts +++ b/frontend/src/lib/components/copilot/chat/global/core.ts @@ -295,7 +295,17 @@ const listWorkspaceItemsSchema = z.object({ .min(1) .max(MAX_LIST_LIMIT) .optional() - .describe('Maximum number of items to return. Defaults to 50 and is capped at 100.') + .describe( + 'Maximum items per item type per page (for triggers, per trigger kind). Defaults to 50 and is capped at 100.' + ), + page: z + .number() + .int() + .min(1) + .optional() + .describe( + 'Page number, starting at 1. Each item type pages independently: request the next page while any type still returns a full page. Drafts appear on page 1 only, capped at limit per type.' + ) }) const readWorkspaceItemSchema = z.object({ @@ -303,7 +313,13 @@ const readWorkspaceItemSchema = z.object({ path: z.string().describe('Workspace path of the item to read.'), trigger_kind: triggerKindSchema .optional() - .describe('Required when type is trigger. Identifies which trigger service to call.') + .describe('Required when type is trigger. Identifies which trigger service to call.'), + version: z + .enum(['deployed']) + .optional() + .describe( + 'Pass "deployed" to read the deployed workspace state even when a draft exists (e.g. to learn the deployed input schema before running the deployed version). Default reads your draft when one exists.' + ) }) const draftOverrideField = z @@ -976,7 +992,8 @@ ${pipelineBullet} - After creating or editing a script or flow draft, run test_run_script, test_run_flow, or test_run_step with representative args before reporting that it works. These tools prefer drafts, so testing does not require deployment. - Use list_runs to find recent runs (optionally filtered by path, creator, label, or status), then get_job_logs with a returned id to inspect a specific run's logs — without starting a new test run. - Use open_page to show a workspace page with filters applied — Runs, Schedules, Variables, Resources, Assets, Audit logs, or Workspace settings on a specific tab (e.g. "open the failed runs of f/foo/bar", "open the schedule for X", "open the git sync settings"). Only the pages listed for this user in the tool are available; don't offer pages that aren't listed. Don't use it as a substitute for list_runs when you just need the data yourself. -- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, running deployed items, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. +- For a Windmill operation no other tool covers (workers, queue state, a run's result or args, ...), use search_api_endpoints to find a REST endpoint, then call_api_get for reads or call_api_endpoint for mutations (the user is asked to confirm those). Always prefer a dedicated tool when one exists; endpoints for authoring or deleting scripts, flows, apps, schedules, resources, or variables are not available through the API catalog tools — use the draft tools and delete_workspace_item instead. +- runScriptByPath / runFlowByPath from the API catalog run the DEPLOYED version of an item. Use them only when the user explicitly asks to run the deployed version, and read the item with read_workspace_item version: "deployed" first so the arguments match the deployed input schema (a draft may have different inputs). To test something you are editing or just wrote, always use test_run_script, test_run_flow, or test_run_step — they run the draft. - When a required decision is ambiguous, use askUserQuestion with two to ten clear proposed answer strings instead of guessing. The user can also type a custom answer when none of the proposed answers fit. Set multiSelect: true only when the answers can genuinely co-apply and the user may pick several (not mutually exclusive). - When the user asks you to remember a lasting preference, always/never do something, or change/stop a behavior going forward, call update_user_instructions to persist it. It edits only the USER INSTRUCTIONS block (not WORKSPACE INSTRUCTIONS). Keep each instruction concise; do not use it for one-off requests scoped to the current task. - Keep context targeted.${ @@ -1062,7 +1079,10 @@ function scriptToItem(script: Script | NewScript, includeValue: boolean): Worksp summary: script.summary, language: script.language, value: includeValue ? script.content : undefined, - isDraft: false + schema: includeValue ? (script as Script).schema : undefined, + // Listings with includeDraftOnly synthesize rows for editor drafts that + // have no deployed counterpart — label those honestly. + isDraft: (script as Script).draft_only ?? false } } @@ -1074,7 +1094,7 @@ function flowToItem(flow: Flow, includeValue: boolean): WorkspaceItem { value: includeValue ? { value: flow.value, schema: flow.schema, groups: flow.value.groups ?? null } : undefined, - isDraft: false + isDraft: (flow as Flow & { draft_only?: boolean }).draft_only ?? false } } @@ -1461,7 +1481,12 @@ function triggerToItem( type TriggerService = { exists(args: { workspace: string; path: string }): Promise get(args: { workspace: string; path: string }): Promise - list(args: { workspace: string; pathStart?: string; perPage?: number }): Promise + list(args: { + workspace: string + pathStart?: string + perPage?: number + page?: number + }): Promise create(args: { workspace: string; requestBody: any }): Promise update(args: { workspace: string; path: string; requestBody: any }): Promise delete(args: { workspace: string; path: string }): Promise @@ -1546,18 +1571,27 @@ async function readWorkspaceItem( type: WorkspaceItemType, path: string, workspace: string, - triggerKind?: TriggerKind + triggerKind?: TriggerKind, + deployedOnly = false ): Promise { switch (type) { case 'script': { - // Prefer the DB draft (newer than the deployed version) when one exists. - const script = await ScriptService.getScriptByPath({ workspace, path, getDraft: true }) - return scriptToItem((script.draft as Script | undefined) ?? script, true) + // Prefer the DB draft (newer than the deployed version) when one exists, + // unless the caller explicitly asked for the deployed state. + const script = await ScriptService.getScriptByPath({ + workspace, + path, + getDraft: !deployedOnly + }) + const draft = deployedOnly ? undefined : (script.draft as Script | undefined) + return scriptToItem(draft ?? script, true) } case 'flow': { - // Prefer the DB draft (newer than the deployed version) when one exists. - const flow = await FlowService.getFlowByPath({ workspace, path, getDraft: true }) - return flowToItem((flow.draft as Flow | undefined) ?? flow, true) + // Prefer the DB draft (newer than the deployed version) when one exists, + // unless the caller explicitly asked for the deployed state. + const flow = await FlowService.getFlowByPath({ workspace, path, getDraft: !deployedOnly }) + const draft = deployedOnly ? undefined : (flow.draft as Flow | undefined) + return flowToItem(draft ?? flow, true) } case 'schedule': return scheduleToItem(await ScheduleService.getSchedule({ workspace, path }), true) @@ -1598,7 +1632,8 @@ async function listWorkspaceItems( types: WorkspaceItemType[], workspace: string, pathPrefix: string | undefined, - perPage: number + perPage: number, + page?: number ): Promise { const items: WorkspaceItem[] = [] @@ -1607,6 +1642,7 @@ async function listWorkspaceItems( workspace, pathStart: pathPrefix, perPage, + page, includeDraftOnly: true, withoutDescription: true }) @@ -1618,6 +1654,7 @@ async function listWorkspaceItems( workspace, pathStart: pathPrefix, perPage, + page, includeDraftOnly: true, withoutDescription: true }) @@ -1628,7 +1665,8 @@ async function listWorkspaceItems( const schedules = await ScheduleService.listSchedules({ workspace, pathStart: pathPrefix, - perPage + perPage, + page }) for (const schedule of schedules) items.push(scheduleToItem(schedule, false)) } @@ -1638,7 +1676,8 @@ async function listWorkspaceItems( const triggers = await triggerServices[kind].list({ workspace, pathStart: pathPrefix, - perPage + perPage, + page }) for (const trigger of triggers) items.push(triggerToItem(kind, trigger, false)) } @@ -1648,7 +1687,8 @@ async function listWorkspaceItems( const resources = await ResourceService.listResource({ workspace, pathStart: pathPrefix, - perPage + perPage, + page }) for (const resource of resources) items.push(resourceToItem(resource, false)) } @@ -1657,7 +1697,8 @@ async function listWorkspaceItems( const variables = await VariableService.listVariable({ workspace, pathStart: pathPrefix, - perPage + perPage, + page }) for (const variable of variables) items.push(variableToItem(variable)) } @@ -1666,7 +1707,8 @@ async function listWorkspaceItems( const apps = await AppService.listApps({ workspace, pathStart: pathPrefix, - perPage + perPage, + page }) for (const app of apps) items.push(appToItem(app, false)) } @@ -2348,7 +2390,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( listWorkspaceItemsSchema, 'list_workspace_items', - 'List workspace items and drafts. Returns metadata only.' + 'List workspace items and drafts. Returns metadata only, up to limit items per item type per page (default 50); pass page to continue past a full page.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = listWorkspaceItemsSchema.parse(args) @@ -2361,24 +2403,37 @@ export const globalTools: Tool<{}>[] = [ types, workspace, parsed.path_prefix, - Math.min(limit, MAX_LIST_LIMIT) + Math.min(limit, MAX_LIST_LIMIT), + parsed.page ) for (const item of workspaceItems) { byKey.set(getWorkspaceItemKey(item.type, item.path, item.triggerKind), item) } - for (const draft of await listGlobalDrafts(workspace)) { - if (!types.includes(draft.type)) continue - if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue - byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { - ...draft, - value: undefined - }) + // Drafts are not paginated server-side; overlay them on page 1 only, + // capped at `limit` per type, so results stay bounded and later pages + // never repeat a page-1 item as its draft twin. Chat draft counts are + // small — past the cap, a narrower path_prefix still finds any draft + // (it filters before the cap; query filters after). + if ((parsed.page ?? 1) === 1) { + const draftCountByType = new Map() + for (const draft of await listGlobalDrafts(workspace)) { + if (!types.includes(draft.type)) continue + if (parsed.path_prefix && !draft.path.startsWith(parsed.path_prefix)) continue + const count = draftCountByType.get(draft.type) ?? 0 + if (count >= limit) continue + draftCountByType.set(draft.type, count + 1) + byKey.set(getWorkspaceItemKey(draft.type, draft.path, draft.triggerKind), { + ...draft, + value: undefined + }) + } } - const results = Array.from(byKey.values()) - .filter((item) => itemMatches(item, parsed.query)) - .slice(0, limit) + // No cross-type truncation: each type is already capped at `limit` rows by + // its own list call, and slicing the concatenation would silently drop the + // later types' rows while their next page skips past them. + const results = Array.from(byKey.values()).filter((item) => itemMatches(item, parsed.query)) toolCallbacks.setToolStatus(toolId, { content: `Listed ${results.length} workspace item(s)` @@ -2390,7 +2445,7 @@ export const globalTools: Tool<{}>[] = [ def: createToolDef( readWorkspaceItemSchema, 'read_workspace_item', - 'Read one workspace item or draft.' + 'Read one workspace item or draft. Prefers your draft when one exists; pass version: "deployed" to read the deployed state instead.' ), fn: async ({ args, workspace, toolId, toolCallbacks }) => { const parsed = readWorkspaceItemSchema.parse(args) @@ -2399,7 +2454,10 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: message, error: message }) return JSON.stringify({ success: false, error: message }) } - const draft = await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) + const draft = + parsed.version === 'deployed' + ? null + : await getGlobalDraft(workspace, parsed.type, parsed.path, parsed.trigger_kind) if (draft) { toolCallbacks.setToolStatus(toolId, { content: `Read draft ${parsed.type} "${parsed.path}"` @@ -2410,7 +2468,13 @@ export const globalTools: Tool<{}>[] = [ toolCallbacks.setToolStatus(toolId, { content: `Reading ${parsed.type} "${parsed.path}"...` }) - const item = await readWorkspaceItem(parsed.type, parsed.path, workspace, parsed.trigger_kind) + const item = await readWorkspaceItem( + parsed.type, + parsed.path, + workspace, + parsed.trigger_kind, + parsed.version === 'deployed' + ) toolCallbacks.setToolStatus(toolId, { content: `Read ${parsed.type} "${parsed.path}"` }) return JSON.stringify(serializeWorkspaceItemForRead(item), null, 2) } diff --git a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts index 21a1c883c3..c17e84cc8f 100644 --- a/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts +++ b/frontend/src/lib/components/copilot/chat/global/workspaceItems.ts @@ -123,6 +123,8 @@ export type WorkspaceItem = { | CreateResource | CreateVariable | AppDraftValue + /** Input schema of a script read (flows carry theirs inside `value`). */ + schema?: unknown isDraft: boolean isLiveDraft?: boolean }