diff --git a/README.md b/README.md index 737278e..f7c7fb6 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,24 @@ $ cloudconvert command --engine=ffmpeg --command=ffmpeg --arguments="-i /input/u ``` Command output is captured and printed by default. Use `--no-capture-output` to disable this. + +#### AI Agent (Beta) + +Describe in plain language what should be done with your files, and an AI agent creates the required tasks. The output files of these tasks are downloaded. If the agent answers a question instead, its answer is printed: + +``` +$ cloudconvert ai/agent --prompt="Convert all files to PDF and merge them into one PDF, in alphabetical order of the file names." *.docx +$ cloudconvert ai/agent --prompt="Make this video smaller, at most 30 MB." input.mp4 +$ cloudconvert ai/agent --prompt="How many pages does this PDF have?" input.pdf +``` + +Files are optional if the prompt names the files to import: + +``` +$ cloudconvert ai/agent --prompt="Convert https://example.com/report.docx to PDF." +``` + +Use `--engine` to choose the AI model. You can list the available models using `cloudconvert parameters ai/agent --alternatives`. The `ai/agent` task itself does not consume credits; the tasks created by the agent consume credits as usual. See CloudConvert's [AI documentation](https://cloudconvert.com/docs/operations/ai) for details. ## All Options @@ -142,6 +160,8 @@ Commands: cloudconvert command Execute custom ffmpeg, imagemagick, or graphicsmagick commands + cloudconvert ai/agent [files..] Let an AI agent process files as + described in a prompt (beta) cloudconvert credits Print the remaining conversion credits of your account cloudconvert parameters List available task parameters for an diff --git a/package-lock.json b/package-lock.json index 19ef20f..34fadef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "glob": "^13.0.6", "log-symbols": "^7.0.1", "ora": "^9.4.0", + "socket.io-client": "^4.8.4", "yargs": "^18.0.0" }, "bin": { @@ -1885,9 +1886,9 @@ } }, "node_modules/socket.io-client": { - "version": "4.8.3", - "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", - "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "version": "4.8.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.4.tgz", + "integrity": "sha512-7wjKAvVXYOJsbmUgkWbnhPD4bFpkqrUR0++XOjpGFmqDqsqV1CI93VTraeExLxpjxEqrWgFoSRZu1ihjLWfkAA==", "license": "MIT", "dependencies": { "@socket.io/component-emitter": "~3.1.0", diff --git a/package.json b/package.json index ae5fd26..0b28ca5 100644 --- a/package.json +++ b/package.json @@ -35,6 +35,7 @@ "glob": "^13.0.6", "log-symbols": "^7.0.1", "ora": "^9.4.0", + "socket.io-client": "^4.8.4", "yargs": "^18.0.0" }, "devDependencies": { diff --git a/src/app.ts b/src/app.ts index 375c844..5c8b44a 100644 --- a/src/app.ts +++ b/src/app.ts @@ -3,6 +3,7 @@ import ora from 'ora'; import yargs from 'yargs/yargs'; import type { Argv } from 'yargs'; +import { createAgentCommand } from './commands/agent.js'; import { createCaptureWebsiteCommand } from './commands/capture-website.js'; import { createCommandCommand } from './commands/command.js'; import { createConvertCommand } from './commands/convert.js'; @@ -20,7 +21,7 @@ import { runJob } from './job.js'; import type { CliArguments, CloudConvertClient, Logger, TaskData } from './types.js'; export interface CliDependencies { - createClient: (apiKey: string, useSandbox: boolean) => CloudConvertClient; + createClient: (apiKey: string, useSandbox: boolean, apiUrl?: string) => CloudConvertClient; runJob: JobRunner; logger: Logger; exitProcess: boolean; @@ -50,6 +51,7 @@ export function createCli(args: string[], partialDependencies: Partial { const cliArguments = argv as unknown as CliArguments; + // Not enumerable, so that it is not sent as task parameter + Object.defineProperty(argv, 'filePatterns', { + value: cliArguments.files ?? [], + enumerable: false, + configurable: true + }); cliArguments.files = expandFilePatterns(cliArguments.files); }) .fail((message, err, yargsInstance) => { diff --git a/src/cloudconvert.ts b/src/cloudconvert.ts index 50cfa8a..da422ea 100644 --- a/src/cloudconvert.ts +++ b/src/cloudconvert.ts @@ -1,7 +1,63 @@ import CloudConvert from 'cloudconvert'; +import { io, type Socket } from 'socket.io-client'; import type { CloudConvertClient } from './types.js'; -export function createCloudConvertClient(apiKey: string, useSandbox: boolean): CloudConvertClient { - return new CloudConvert(apiKey, useSandbox) as unknown as CloudConvertClient; +export function createCloudConvertClient(apiKey: string, useSandbox: boolean, apiUrl?: string): CloudConvertClient { + const client = apiUrl ? new CustomEndpointCloudConvert(apiKey, apiUrl) : new CloudConvert(apiKey, useSandbox); + + return client as unknown as CloudConvertClient; +} + +/** + * Sends the API calls to another endpoint, such as a local development server. The socket is + * expected on the host with "socketio." instead of "api.", as with api.cloudconvert.com. + */ +class CustomEndpointCloudConvert extends CloudConvert { + private readonly baseUrl: string; + private readonly socketUrl: string; + + constructor(apiKey: string, apiUrl: string) { + super(apiKey); + this.baseUrl = normalizeApiUrl(apiUrl); + this.socketUrl = getSocketUrl(this.baseUrl); + } + + override async call(...[method, route, parameters, options]: Parameters): Promise { + return await this.callWithBase(this.baseUrl, method, route, parameters, options); + } + + override subscribe(...args: Parameters): void { + // The SDK connects to the socket of api.cloudconvert.com, unless it is connected already + const state = this as unknown as { socket?: Socket; subscribedChannels?: Map }; + + if (!state.socket) { + state.socket = io(this.socketUrl, { transports: ['websocket'] }); + state.subscribedChannels = new Map(); + } + + super.subscribe(...args); + } +} + +export function normalizeApiUrl(apiUrl: string): string { + const url = new URL(apiUrl); + + if (url.pathname === '/') { + url.pathname = '/v2/'; + } else if (!url.pathname.endsWith('/')) { + url.pathname += '/'; + } + + return url.toString(); +} + +export function getSocketUrl(apiUrl: string): string { + const url = new URL(apiUrl); + + if (url.hostname.startsWith('api.')) { + url.hostname = `socketio.${url.hostname.slice('api.'.length)}`; + } + + return url.origin; } diff --git a/src/commands/agent.ts b/src/commands/agent.ts new file mode 100644 index 0000000..2f604af --- /dev/null +++ b/src/commands/agent.ts @@ -0,0 +1,59 @@ +import type { Argv, CommandModule } from 'yargs'; + +import type { CliArguments } from '../types.js'; +import { addTaskParameterMiddleware, collectTaskParameters } from './task-parameters.js'; +import type { JobRunner } from './types.js'; + +interface AgentArguments extends CliArguments { + prompt: string; + engine?: string; +} + +export function createAgentCommand(runJob: JobRunner): CommandModule { + return { + command: 'ai/agent [files..]', + aliases: ['ai-agent', 'agent', 'ai'], + describe: 'Let an AI agent process files as described in a prompt (beta)', + builder: yargs => buildAgentOptions(yargs), + handler: async rawArgv => { + const argv = rawArgv as unknown as AgentArguments; + + // Files are optional, as the prompt can name the files to import, but not a mistyped path + if ((argv.filePatterns ?? []).length > 0 && (argv.files ?? []).length < 1) { + throw new Error(`No files found: ${(argv.filePatterns ?? []).join(', ')}`); + } + + await runJob(argv, { + operation: 'ai/agent', + prompt: argv.prompt, + ...(argv.engine ? { engine: argv.engine } : {}), + ...collectTaskParameters(argv) + }); + } + }; +} + +function buildAgentOptions(argv: Argv): Argv { + return addTaskParameterMiddleware( + argv + .positional('files', { + description: 'Path(s) to file(s) the agent works on. Optional if the prompt names the files to import' + }) + .option('prompt', { + describe: 'Describe in plain language what should be done with the files', + type: 'string', + demandOption: true + }) + .option('engine', { + describe: 'Set the AI model. List the models with: cloudconvert parameters ai/agent --alternatives', + type: 'string' + }) + .example('cloudconvert ai/agent --prompt="Convert to PDF/A-2b." input.pdf', '') + .example('cloudconvert ai/agent --prompt="Make this video smaller, at most 30 MB." input.mp4', '') + .example('cloudconvert ai/agent --prompt="How many pages does this PDF have?" input.pdf', '') + .example('cloudconvert ai/agent --prompt="Convert https://example.com/report.docx to PDF."', '') + ) + .help() + .hide('version') + .epilogue('Find possible task parameters with: cloudconvert parameters ai/agent'); +} diff --git a/src/commands/parameters.ts b/src/commands/parameters.ts index 987db8e..87d8df9 100644 --- a/src/commands/parameters.ts +++ b/src/commands/parameters.ts @@ -21,7 +21,8 @@ export function createParametersCommand(): CommandModule { builder: yargs => yargs .positional('operation', { - description: 'Operation to inspect, for example: convert, optimize, thumbnail, watermark or capture-website', + description: + 'Operation to inspect, for example: convert, optimize, thumbnail, watermark, capture-website or ai/agent', type: 'string' }) .option('input-format', { diff --git a/src/job.ts b/src/job.ts index c2f98f7..ff1db45 100644 --- a/src/job.ts +++ b/src/job.ts @@ -36,6 +36,8 @@ interface InterruptSignalEmitter { const JOB_STATUS_POLL_INTERVAL_MS = 30_000; const JOB_CANCELLED_MESSAGE = 'Job cancelled.'; +const AGENT_OPERATION = 'ai/agent'; +const AGENT_NO_OUTPUT_CODE = 'AGENT_NO_OUTPUT'; export interface RunJobDependencies { spinner: Spinner; @@ -54,12 +56,15 @@ export function buildJobTemplate( process: createProcessTaskData(taskData, secondaryInputFiles), export: { operation: 'export/url', - input: 'process' + input: 'process', + // The export of an agent which has answered a question without output files is cancelled + ...(taskData.operation === AGENT_OPERATION ? { ignore_error: true } : {}) } } }; - if (files) { + // ai/agent tasks can import the files named in the prompt instead + if (files && files.length > 0) { jobData.tasks.process.input = []; files.forEach((_, index) => { @@ -193,6 +198,7 @@ async function waitForCompletion( if (failedJob) { printFailedTaskMessages(failedJob.tasks, logger); printCommandTaskOutputs(failedJob.tasks, logger); + printAgentSuggestions(failedJob.tasks, logger); } spinner.fail('Job failed!'); reject(new Error('Job failed!')); @@ -224,6 +230,7 @@ async function finishCompletedJob( spinner.succeed('Done!'); printTaskMessages(finishedJob.tasks, logger); printCommandTaskOutputs(finishedJob.tasks, logger); + printAgentSuggestions(finishedJob.tasks, logger); return finishedJob; } catch (error) { closeCloudConvertSocket(argv.cloudconvert); @@ -283,11 +290,17 @@ function subscribeToJobEvents( }); void cloudconvert.jobs.subscribeTaskEvent(jobId, 'failed', event => { - logger.error( - `Task \`${event.task.name}\` failed: ${event.task.message ?? ''} (Code: ${ - event.task.code ?? 'UNKNOWN_ERROR' - })` - ); + const message = `Task \`${event.task.name}\` failed: ${event.task.message ?? ''} (Code: ${ + event.task.code ?? 'UNKNOWN_ERROR' + })`; + + // The agent tries to correct the cause of a failed task it has created + if (event.task.parent_task_id) { + logger.info(message); + return; + } + + logger.error(message); }); void cloudconvert.jobs.subscribeEvent(jobId, 'failed', event => { @@ -401,11 +414,23 @@ async function downloadExportedFiles( } function printTaskMessages(tasks: JobTask[], logger: Logger): void { - for (const task of tasks.filter(task => task.message)) { + for (const task of tasks.filter(task => task.message && task.code !== AGENT_NO_OUTPUT_CODE)) { logger.info(`Task \`${task.name}\`: ${task.message}`); } } +function printAgentSuggestions(tasks: JobTask[], logger: Logger): void { + for (const task of tasks) { + const suggestions = task.result?.suggestions; + + if (task.operation !== AGENT_OPERATION || !Array.isArray(suggestions) || suggestions.length === 0) { + continue; + } + + logger.info(`Task \`${task.name}\` suggestions:\n${suggestions.map(suggestion => `- ${suggestion}`).join('\n')}`); + } +} + function printCommandTaskOutputs(tasks: JobTask[], logger: Logger): void { for (const task of tasks) { if (task.operation !== 'command' || typeof task.result?.output !== 'string' || task.result.output.length === 0) { diff --git a/src/types.ts b/src/types.ts index 235a9c6..90fc0c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export interface CliArguments { overwrite: boolean; parameter?: ParameterValues; files?: string[]; + filePatterns?: string[]; format?: string; url?: string; cloudconvert: CloudConvertClient; diff --git a/test/cli.test.ts b/test/cli.test.ts index 4d2b9b8..093c8f0 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -34,6 +34,7 @@ describe('CLI compatibility', () => { expect(help).toContain('cloudconvert pdf/extract-pages '); expect(help).toContain('cloudconvert pdf/rotate-pages '); expect(help).toContain('cloudconvert command '); + expect(help).toContain('cloudconvert ai/agent [files..]'); expect(help).toContain('cloudconvert credits'); expect(help).toContain('cloudconvert parameters '); expect(help).toContain('--api-key'); @@ -76,6 +77,7 @@ describe('CLI compatibility', () => { const captureWebsiteHelp = await createCli(['capture-website', '--help'], dependencies).getHelp(); const commandHelp = await createCli(['command', '--help'], dependencies).getHelp(); const pdfOcrHelp = await createCli(['pdf/ocr', '--help'], dependencies).getHelp(); + const agentHelp = await createCli(['ai/agent', '--help'], dependencies).getHelp(); expect(convertHelp).toContain('Find possible task parameters with: cloudconvert parameters convert'); expect(captureWebsiteHelp).toContain( @@ -83,6 +85,7 @@ describe('CLI compatibility', () => { ); expect(commandHelp).toContain('Find possible task parameters with: cloudconvert parameters command'); expect(pdfOcrHelp).toContain('Find possible task parameters with: cloudconvert parameters pdf/ocr'); + expect(agentHelp).toContain('Find possible task parameters with: cloudconvert parameters ai/agent'); }); it('parses convert with -f and dotted -p parameters', async () => { @@ -273,6 +276,78 @@ describe('CLI compatibility', () => { }); }); + it('parses ai/agent prompt and model', async () => { + const { files, cleanup } = await createTempFiles(['input.pdf']); + cleanups.push(cleanup); + const calls = await parseJobCommand([ + 'ai/agent', + '--prompt=Convert to PDF/A-2b.', + '--engine=anthropic/claude-haiku-5.5', + files[0] + ]); + + expect(calls[0].argv.files).toEqual([files[0]]); + expect(calls[0].taskData).toEqual({ + operation: 'ai/agent', + prompt: 'Convert to PDF/A-2b.', + engine: 'anthropic/claude-haiku-5.5' + }); + }); + + it('keeps ai-agent, agent and ai as aliases for ai/agent', async () => { + const { files, cleanup } = await createTempFiles(['input.pdf']); + cleanups.push(cleanup); + + for (const alias of ['ai-agent', 'agent', 'ai']) { + const calls = await parseJobCommand([alias, '--prompt=How many pages?', files[0]]); + + expect(calls[0].taskData).toEqual({ operation: 'ai/agent', prompt: 'How many pages?' }); + } + }); + + it('allows ai/agent without files when the prompt names them', async () => { + const calls = await parseJobCommand(['ai', '--prompt=Convert https://example.com/report.docx to PDF.']); + + expect(calls[0].argv.files).toEqual([]); + expect(calls[0].taskData).toEqual({ + operation: 'ai/agent', + prompt: 'Convert https://example.com/report.docx to PDF.' + }); + }); + + it('rejects ai/agent file paths which match no files', async () => { + await expect(parseJobCommand(['ai', '--prompt=Convert to PDF.', 'missing.docx'])).rejects.toThrow( + 'No files found: missing.docx' + ); + }); + + it('requires a prompt for ai/agent', async () => { + const { files, cleanup } = await createTempFiles(['input.pdf']); + cleanups.push(cleanup); + + await expect(parseJobCommand(['ai/agent', files[0]])).rejects.toThrow('Missing required argument: prompt'); + }); + + it('passes --api-url to the client without sending it as task parameter', async () => { + const { files, cleanup } = await createTempFiles(['input.pdf']); + cleanups.push(cleanup); + const createClient = vi.fn(() => createFakeCloudConvertClient()); + const calls: TaskData[] = []; + + await createCli(['--api-key', 'key', '--api-url', 'https://api.cloudconvert.localhost', 'optimize', files[0]], { + exitProcess: false, + logger: createSilentLogger(), + createClient, + runJob: async (_argv, taskData) => { + calls.push(taskData); + return createFakeJob(); + } + }).parseAsync(); + + expect(createClient).toHaveBeenCalledWith('key', false, 'https://api.cloudconvert.localhost'); + expect(calls[0]).toEqual({ operation: 'optimize' }); + }); + it('uses credits as the API account command', async () => { const client = createFakeCloudConvertClient(12); const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); diff --git a/test/cloudconvert.test.ts b/test/cloudconvert.test.ts new file mode 100644 index 0000000..cccf45c --- /dev/null +++ b/test/cloudconvert.test.ts @@ -0,0 +1,30 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createCloudConvertClient, getSocketUrl, normalizeApiUrl } from '../src/cloudconvert.js'; + +describe('CloudConvert client', () => { + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('adds the API version to an API URL without path', () => { + expect(normalizeApiUrl('https://api.cloudconvert.localhost')).toBe('https://api.cloudconvert.localhost/v2/'); + expect(normalizeApiUrl('https://api.cloudconvert.localhost/v2')).toBe('https://api.cloudconvert.localhost/v2/'); + expect(normalizeApiUrl('http://localhost:8089/v2/')).toBe('http://localhost:8089/v2/'); + }); + + it('expects the socket on the socketio host of the API', () => { + expect(getSocketUrl('https://api.cloudconvert.localhost/v2/')).toBe('https://socketio.cloudconvert.localhost'); + expect(getSocketUrl('http://localhost:8089/v2/')).toBe('http://localhost:8089'); + }); + + it('sends API calls to the custom API URL', async () => { + const fetchMock = vi.fn(async () => Response.json({ data: { credits: 42 } })); + vi.stubGlobal('fetch', fetchMock); + + const client = createCloudConvertClient('key', false, 'https://api.cloudconvert.localhost'); + await expect(client.users.me()).resolves.toEqual({ credits: 42 }); + + expect(String((fetchMock.mock.calls[0] as unknown[])[0])).toBe('https://api.cloudconvert.localhost/v2/users/me'); + }); +}); diff --git a/test/job.test.ts b/test/job.test.ts index bed636b..e2d5647 100644 --- a/test/job.test.ts +++ b/test/job.test.ts @@ -4,7 +4,7 @@ import { basename, join } from 'node:path'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { buildJobTemplate, downloadExportFile, runJob } from '../src/job.js'; -import type { CliArguments, CloudConvertClient, Job, JobEvent, Spinner } from '../src/types.js'; +import type { CliArguments, CloudConvertClient, Job, JobEvent, Spinner, TaskEvent } from '../src/types.js'; import { createFakeCloudConvertClient, createSilentLogger, createTempFiles } from './helpers.js'; describe('job services', () => { @@ -46,6 +46,30 @@ describe('job services', () => { }); }); + it('lets the export of ai/agent jobs ignore a missing agent output', () => { + expect(buildJobTemplate({ operation: 'ai/agent', prompt: 'How many pages?' }, ['input.pdf']).tasks.export).toEqual({ + operation: 'export/url', + input: 'process', + ignore_error: true + }); + }); + + it('builds ai/agent jobs without input files', () => { + expect(buildJobTemplate({ operation: 'ai/agent', prompt: 'Convert https://example.com/report.docx to PDF.' }, [])).toEqual({ + tasks: { + process: { + operation: 'ai/agent', + prompt: 'Convert https://example.com/report.docx to PDF.' + }, + export: { + operation: 'export/url', + input: 'process', + ignore_error: true + } + } + }); + }); + it('builds upload/import tasks for secondary input parameters', () => { expect( buildJobTemplate( @@ -175,6 +199,99 @@ describe('job services', () => { expect(logger.info).toHaveBeenCalledWith('Task `process` output:\nffmpeg output'); }); + it('prints the agent answer and suggestions without the cancelled export', async () => { + const { files, cleanup } = await createTempFiles(['input.pdf']); + cleanups.push(cleanup); + const job = createUploadJob(); + const finishedJob: Job = { + id: 'job-id', + tasks: [ + { + name: 'export', + operation: 'export/url', + status: 'error', + message: 'The agent task has produced no output files', + code: 'AGENT_NO_OUTPUT' + }, + { + name: 'process', + operation: 'ai/agent', + status: 'finished', + message: 'The PDF has 12 pages.', + result: { + output_tasks: [], + suggestions: ['Convert the first page to PNG.', 'Split the PDF into single pages.'] + } + } + ] + }; + const client = createFakeCloudConvertClient() as CloudConvertClient; + const logger = createSilentLogger(); + const downloadFile = vi.fn(async () => 'downloaded' as const); + let finishedCallback: ((event: JobEvent) => void) | undefined; + + vi.mocked(client.jobs.create).mockResolvedValue(job); + vi.mocked(client.jobs.subscribeEvent).mockImplementation((_id, event, callback) => { + if (event === 'finished') { + finishedCallback = callback; + } + }); + vi.mocked(client.tasks.upload).mockResolvedValue(undefined); + + const jobPromise = runJob(createArgv(client, files), { operation: 'ai/agent', prompt: 'How many pages?' }, { + spinner: createSpinner(), + logger, + downloadFile + }); + + await vi.waitFor(() => expect(client.tasks.upload).toHaveBeenCalledOnce()); + finishedCallback?.({ job: finishedJob }); + await expect(jobPromise).resolves.toBe(finishedJob); + + expect(downloadFile).not.toHaveBeenCalled(); + expect(logger.info).toHaveBeenCalledWith('Task `process`: The PDF has 12 pages.'); + expect(logger.info).toHaveBeenCalledWith( + 'Task `process` suggestions:\n- Convert the first page to PNG.\n- Split the PDF into single pages.' + ); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('AGENT_NO_OUTPUT')); + expect(logger.info).not.toHaveBeenCalledWith(expect.stringContaining('no output files')); + }); + + it('logs failed tasks created by the agent as info, as the agent may correct them', async () => { + const client = createFakeCloudConvertClient() as CloudConvertClient; + const logger = createSilentLogger(); + let taskFailedCallback: ((event: TaskEvent) => void) | undefined; + + vi.mocked(client.jobs.create).mockResolvedValue({ id: 'job-id', tasks: [] }); + vi.mocked(client.jobs.subscribeTaskEvent).mockImplementation((_id, _event, callback) => { + taskFailedCallback = callback; + }); + + void runJob(createArgv(client, []), { operation: 'ai/agent', prompt: 'Convert to PDF.' }, { + spinner: createSpinner(), + logger, + signalEmitter: createSignalEmitter() + }).catch(() => undefined); + + await vi.waitFor(() => expect(taskFailedCallback).toBeDefined()); + taskFailedCallback?.({ + task: { + name: 'convert-1', + operation: 'convert', + parent_task_id: 'agent-task-id', + message: 'Unsupported option', + code: 'INVALID_OPTIONS' + } + }); + taskFailedCallback?.({ + task: { name: 'process', operation: 'ai/agent', message: 'The agent gave up', code: 'AGENT_FAILED' } + }); + + expect(logger.info).toHaveBeenCalledWith('Task `convert-1` failed: Unsupported option (Code: INVALID_OPTIONS)'); + expect(logger.error).toHaveBeenCalledWith('Task `process` failed: The agent gave up (Code: AGENT_FAILED)'); + expect(logger.error).not.toHaveBeenCalledWith(expect.stringContaining('convert-1')); + }); + it('uploads secondary input files from Input task parameters', async () => { const { files, cleanup } = await createTempFiles(['input.pdf', 'watermark.png']); cleanups.push(cleanup);