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
126 changes: 126 additions & 0 deletions src/__tests__/commands/agent-cancellation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { execFile } from 'node:child_process';
import { createServer, type Server } from 'node:http';
import { mkdtempSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { promisify } from 'node:util';
import { afterAll, beforeAll, expect, it } from 'vitest';

const exec = promisify(execFile);
const JOB_ID = '019e5299-8235-7538-9f62-bc39d4b058f1';
const home = mkdtempSync(join(tmpdir(), 'agent-cancellation-cli-'));
let server: Server;
let baseUrl: string;
let statuses: string[];
let polls: number;

beforeAll(async () => {
server = createServer((req, res) => {
res.writeHead(200, { 'content-type': 'application/json' });
if (req.method === 'POST') {
res.end(JSON.stringify({ success: true, id: JOB_ID }));
return;
}
const status = statuses[Math.min(polls++, statuses.length - 1)];
res.end(
JSON.stringify({
success: true,
status,
data: { answer: 'result' },
creditsUsed: 12,
threadId: 'thread-fixture',
error: status === 'failed' ? 'Agent reached max credits' : undefined,
})
);
});
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve));
baseUrl = `http://127.0.0.1:${(server.address() as { port: number }).port}`;
});

afterAll(async () => {
await new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
);
rmSync(home, { recursive: true, force: true });
});

async function cli(sequence: string[], prompt = 'research example') {
statuses = sequence;
polls = 0;
try {
return {
code: 0,
...(await exec(
process.execPath,
[
'dist/index.js',

@cubic-dev-ai cubic-dev-ai Bot Oct 1, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P3: This test spawns the compiled dist/index.js, but pnpm test (vitest run) builds nothing, so on a clean checkout the CLI spawn fails with ENOENT and every case errors out — or, after unrelated source edits, the suite quietly tests a stale build. Build the project before running the suite (e.g., a pretest script or CI build step) or have cli() fail with a clear message when dist/ is missing.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At src/__tests__/commands/agent-cancellation.test.ts, line 56:

<comment>This test spawns the compiled `dist/index.js`, but `pnpm test` (`vitest run`) builds nothing, so on a clean checkout the CLI spawn fails with ENOENT and every case errors out — or, after unrelated source edits, the suite quietly tests a stale build. Build the project before running the suite (e.g., a `pretest` script or CI `build` step) or have `cli()` fail with a clear message when `dist/` is missing.</comment>

<file context>
@@ -0,0 +1,126 @@
+      ...(await exec(
+        process.execPath,
+        [
+          'dist/index.js',
+          'agent',
+          prompt,
</file context>
Fix with cubic

'agent',
prompt,
'--wait',
'--poll-interval',
'0.01',
'--timeout',
'1',
'--json',
],
{
timeout: 10000,
env: {
...process.env,
HOME: home,
USERPROFILE: home,
FIRECRAWL_API_KEY: 'fc-test',
FIRECRAWL_API_URL: baseUrl,
FIRECRAWL_NO_UPDATE_CHECK: '1',
},
}
)),
};
} catch (error) {
return error as { code: number; stdout: string; stderr: string };
}
}

it.each([['cancelled'], ['processing', 'cancelled']])(
'stops prompt-start polling at cancellation (%j)',
async (...sequence) => {
const result = await cli(sequence);
expect(result.code).toBe(0);
expect(polls).toBe(sequence.length);
expect(JSON.parse(result.stdout)).toMatchObject({
success: true,
id: JOB_ID,
status: 'cancelled',
creditsUsed: 12,
threadId: 'thread-fixture',
});
expect(result.stderr).not.toContain('still processing');
}
);

it('keeps existing-job cancellation successful', async () => {
const result = await cli(['cancelled'], JOB_ID);
expect(result.code).toBe(0);
expect(polls).toBe(1);
expect(JSON.parse(result.stdout).status).toBe('cancelled');
});

it('keeps processing-to-completed successful', async () => {
const result = await cli(['processing', 'completed']);
expect(result.code).toBe(0);
expect(polls).toBe(2);
expect(JSON.parse(result.stdout).data).toEqual({ answer: 'result' });
});

it('keeps failed jobs unsuccessful', async () => {
const result = await cli(['failed']);
expect(result.code).toBe(1);
expect(polls).toBe(1);
expect(result.stderr).toContain('Agent reached max credits');
});

it('still times out when the agent remains processing', async () => {
const result = await cli(['processing']);
expect(result.code).toBe(1);
expect(result.stderr).toContain('Agent still processing');
});
8 changes: 8 additions & 0 deletions src/commands/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -352,6 +352,14 @@ export async function executeAgent(
};
}

if (normalizedStatus === 'cancelled') {
spinner.succeed('Agent cancelled');
return {
success: true,
data: toStatusData(jobId, agentStatus, normalizedStatus),
};
}

// Check timeout
if (timeoutMs && Date.now() - startTime > timeoutMs) {
process.removeListener('SIGINT', handleInterrupt);
Expand Down