Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 39 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -660,6 +660,38 @@ firecrawl agent <job-id>
firecrawl agent <job-id> --wait
```

#### Threads

An agent run belongs to a thread. Follow-ups continue that thread with the full
context of the earlier turns, so you only send the new question. Every start
prints its thread ID, and the last thread you started is remembered per API key.

```bash
# Start a conversation; chat mode lets the agent answer in prose
firecrawl agent --mode chat "List the pricing tiers on example.com" --wait

# Follow up on the last thread started with this API key
firecrawl agent --continue "Which tier includes SSO?" --wait

# Continue a specific thread, or force a new one
firecrawl agent --thread <thread-id> "And the annual price?" --wait
firecrawl agent --new "Start over on example.org" --wait

# Print a whole conversation
firecrawl agent thread <thread-id>
```

A follow-up inherits the URLs and schema of the previous turn unless you say
otherwise, so there are two flags to drop them:

```bash
# Stop focusing on the URLs from earlier turns
firecrawl agent --continue --no-urls "Look anywhere on the site now" --wait

# Drop the schema and let the agent answer in prose
firecrawl agent --continue --no-schema --mode chat "Summarise what changed" --wait
```

#### Agent Options

| Option | Description |
Expand All @@ -670,6 +702,13 @@ firecrawl agent <job-id> --wait
| `--schema-file <path>` | Path to JSON schema file for structured output |
| `--max-credits <number>` | Maximum credits to spend (job fails if exceeded) |
| `--webhook <url-or-json>` | Webhook URL or configuration |
| `--no-urls` | Drop the URLs inherited from the thread (follow-ups only) |
| `--no-schema` | Drop the schema inherited from the thread (follow-ups only) |
| `--thread <id>` | Continue the thread with this ID |
| `--continue` | Continue the last thread started with this API key |
| `--new` | Ignore any remembered thread and start a new one |
| `--mode <mode>` | `extract` (default, returns JSON) or `chat` (prose replies) |
| `--effort <level>` | Effort level: `low`, `medium`, or `high` |
| `--status` | Check status of existing agent job |
| `--cancel` | Cancel an active agent job by job ID |
| `--wait` | Wait for agent to complete before returning results |
Expand Down
155 changes: 153 additions & 2 deletions src/__tests__/cli-argv.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,50 @@
import { spawnSync } from 'node:child_process';
import { existsSync } from 'node:fs';
import { resolve } from 'node:path';
import { existsSync, mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { describe, expect, it } from 'vitest';

describe('CLI argv parsing', () => {
const cliPath = resolve(process.cwd(), 'dist/index.js');
const testWithBuiltCli = existsSync(cliPath) ? it : it.skip;

/**
* A run that gets as far as the argument checks.
*
* Every other case here asks for `--help`, which Commander answers before
* any command runs. A case that reaches a command does not: without a key
* the CLI stops at its login prompt, and with no stdin to answer it exits 0.
* That is the difference between a developer's machine and CI, and it is
* what let these two pass locally while failing there.
*
* The home directory is thrown away too, so a remembered thread or a stored
* key on the machine running the tests cannot change the answer. The key is
* never spent: every case below is rejected before a request is made.
*/
const runAuthedCli = (args: string[]) => {
const home = mkdtempSync(join(tmpdir(), 'firecrawl-cli-argv-'));
try {
return spawnSync(process.execPath, [cliPath, ...args], {
cwd: process.cwd(),
encoding: 'utf8',
// None of these cases should reach a network round trip, so anything
// that blocks is a broken assumption. Failing on it beats a suite that
Comment on lines +30 to +31

@cubic-dev-ai cubic-dev-ai Bot Sep 4, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P3: The new comment says "None of these cases should reach a network round trip," but the 'leaves URLs and schema unset' test in this same file deliberately passes --api-url http://127.0.0.1:9 to reach a failed request. Drop the claim or reword to "no remote call that would block," since the point of the timeout is to fail a hang, and the localhost refusal is the very round trip the assertion relies on.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/__tests__/cli-argv.test.ts, line 30:

<comment>The new comment says "None of these cases should reach a network round trip," but the 'leaves URLs and schema unset' test in this same file deliberately passes `--api-url http://127.0.0.1:9` to reach a failed request. Drop the claim or reword to "no remote call that would block," since the point of the timeout is to fail a hang, and the localhost refusal is the very round trip the assertion relies on.</comment>

<file context>
@@ -27,6 +27,11 @@ describe('CLI argv parsing', () => {
       return spawnSync(process.execPath, [cliPath, ...args], {
         cwd: process.cwd(),
         encoding: 'utf8',
+        // None of these cases should reach a network round trip, so anything
+        // that blocks is a broken assumption. Failing on it beats a suite that
+        // hangs until the runner gives up.
</file context>
Suggested change
// None of these cases should reach a network round trip, so anything
// that blocks is a broken assumption. Failing on it beats a suite that
// None of these cases should make a blocking network call, so anything
// that hangs is a broken assumption. Failing on it beats a suite that
Fix with cubic

// hangs until the runner gives up.
timeout: 20_000,
killSignal: 'SIGKILL',
env: {
...process.env,
HOME: home,
USERPROFILE: home,
FIRECRAWL_API_KEY: 'fc-argv-test',
FIRECRAWL_NO_TELEMETRY: '1',
},
});
} finally {
rmSync(home, { recursive: true, force: true });
}
};

testWithBuiltCli('lists the developer command in root help output', () => {
const result = spawnSync(process.execPath, [cliPath, '--help'], {
cwd: process.cwd(),
Expand Down Expand Up @@ -97,6 +135,119 @@ describe('CLI argv parsing', () => {
}
);

testWithBuiltCli('exposes the agent thread flags and subcommand', () => {
const result = spawnSync(process.execPath, [cliPath, 'agent', '--help'], {
cwd: process.cwd(),
encoding: 'utf8',
});

expect(result.status).toBe(0);
const flattened = result.stdout.replace(/\s+/g, ' ');
for (const flag of [
'--thread',
'--continue',
'--new',
'--mode',
'--effort',
]) {
expect(flattened).toContain(flag);
}
expect(flattened).toContain('thread [options] <threadId>');
expect(result.stderr).not.toContain('unknown command');
});

testWithBuiltCli('offers flags that clear inherited URLs and schema', () => {
const result = spawnSync(process.execPath, [cliPath, 'agent', '--help'], {
cwd: process.cwd(),
encoding: 'utf8',
});

expect(result.status).toBe(0);
const flattened = result.stdout.replace(/\s+/g, ' ');
expect(flattened).toContain('--no-urls');
expect(flattened).toContain('--no-schema');
});

/**
* `--urls` and `--no-urls` share one attribute, and so do the schema pair.
* A run that passes neither must reach the request with both unset: leaking
* anything else into them puts a non-string through the URL split or the
* schema parse and kills the command before it asks for anything.
*/
testWithBuiltCli(
'leaves URLs and schema unset when neither flag is passed',
() => {
const result = runAuthedCli([
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
'agent',
'a prompt',
'--api-url',
'http://127.0.0.1:9',
]);
const output = `${result.stdout}${result.stderr}`;

// Getting as far as a failed request is the assertion, because option
// parsing is behind it and a non-string in either value would have
// thrown on the way. Which failure it is does not matter, so nothing
// here depends on that port being refused rather than answered.
expect(output).toContain('Failed to start agent');
expect(output).not.toMatch(/is not a function/);
}
);

testWithBuiltCli('requires a thread to clear URLs or schema', () => {
for (const flag of ['--no-urls', '--no-schema']) {
const result = runAuthedCli(['agent', flag, 'a prompt']);

expect(result.status).toBe(1);
expect(result.stderr).toContain(
'only apply to a follow-up. Pass --thread <id> or --continue.'
);
}
});

testWithBuiltCli('rejects clearing a value that is also being set', () => {
const urls = runAuthedCli([
'agent',
'--continue',
'--urls',
'https://example.com',
'--no-urls',
'a prompt',
]);

expect(urls.status).toBe(1);
expect(urls.stderr).toContain('use --urls or --no-urls, not both.');

const schema = runAuthedCli([
'agent',
'--continue',
'--schema',
'{"type":"object"}',
'--no-schema',
'a prompt',
]);

expect(schema.status).toBe(1);
expect(schema.stderr).toContain(
'use --schema/--schema-file or --no-schema, not both.'
);
});

testWithBuiltCli('parses the agent thread subcommand', () => {
const result = spawnSync(
process.execPath,
[cliPath, 'agent', 'thread', '--help'],
{
cwd: process.cwd(),
encoding: 'utf8',
}
);

expect(result.status).toBe(0);
expect(result.stdout).toContain('Usage: firecrawl agent thread');
expect(result.stderr).not.toContain('unknown command');
});

testWithBuiltCli(
'exposes explicit keyless MCP setup and launch flags',
() => {
Expand Down
Loading
Loading