From 87a02fb4984f58dfb5008b8bdbb5a86dfa80cffe Mon Sep 17 00:00:00 2001 From: Qwen Code Bot Date: Thu, 30 Jul 2026 13:58:59 +0000 Subject: [PATCH] test(integration): harden acp-cron diagnostics and isolation (#8076) Fail fast when the cron_create tool call is not served to the first user prompt instead of timing out opaquely 75s later, and dump the fake server request log on failure so a dispatch shift is quick to diagnose. Close the fake server even when test setup throws, drop the dead FAKE_SERVER_OPTIONS (container mode is skipped by IS_SANDBOX), and move QWEN_HOME out of the agent workspace cwd so workspace scans never see it. --- integration-tests/cli/acp-cron.test.ts | 151 ++++++++++++++----------- 1 file changed, 88 insertions(+), 63 deletions(-) diff --git a/integration-tests/cli/acp-cron.test.ts b/integration-tests/cli/acp-cron.test.ts index 3104dd7f7c..7ca52d089c 100644 --- a/integration-tests/cli/acp-cron.test.ts +++ b/integration-tests/cli/acp-cron.test.ts @@ -17,12 +17,12 @@ */ import { spawn } from 'node:child_process'; -import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'; -import { join } from 'node:path'; +import { mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { basename, dirname, join } from 'node:path'; import { createInterface } from 'node:readline'; import { setTimeout as delay } from 'node:timers/promises'; import { describe, it, expect } from 'vitest'; -import { TestRig, fakeServerHostOptions } from '../test-helper.js'; +import { TestRig } from '../test-helper.js'; import { startFakeOpenAIServer, fakeToolCall, @@ -35,8 +35,6 @@ const IS_SANDBOX = process.env['QWEN_SANDBOX'] && process.env['QWEN_SANDBOX']!.toLowerCase() !== 'false'; -const FAKE_SERVER_OPTIONS = fakeServerHostOptions(); - type PendingRequest = { resolve: (value: unknown) => void; reject: (reason: Error) => void; @@ -87,7 +85,12 @@ function setupAcpCronTest(rig: TestRig, fakeServer: FakeOpenAIServer) { })[] = []; const stderr: string[] = []; - const qwenHome = join(rig.testDir!, '.qwen-home'); + // Keep the global config dir outside the agent's workspace cwd so workspace + // scans (memory discovery, file tooling) never see it. + const qwenHome = join( + dirname(rig.testDir!), + `${basename(rig.testDir!)}-home`, + ); mkdirSync(qwenHome, { recursive: true }); const agent = spawn( @@ -103,6 +106,7 @@ function setupAcpCronTest(rig: TestRig, fakeServer: FakeOpenAIServer) { OPENAI_BASE_URL: fakeServer.baseUrl, OPENAI_MODEL: 'fake-model', QWEN_MODEL: 'fake-model', + // Defends against an ambient proxy intercepting the local fake server. NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost', }, @@ -281,6 +285,7 @@ function setupAcpCronTest(rig: TestRig, fakeServer: FakeOpenAIServer) { pending.forEach(({ timeout }) => clearTimeout(timeout)); pending.clear(); await waitForExit(); + rmSync(qwenHome, { recursive: true, force: true }); }; return { @@ -340,72 +345,92 @@ async function initSession( }; } return { content: 'Done.' }; - }, FAKE_SERVER_OPTIONS); - - const { sendRequest, cleanup, stderr, waitForSessionUpdate } = - setupAcpCronTest(rig, fakeServer); + }); try { - const sessionId = await initSession(sendRequest, rig.testDir!); + const { sendRequest, cleanup, stderr, waitForSessionUpdate } = + setupAcpCronTest(rig, fakeServer); - // --- Part 1: Create a cron job that fires every minute --- - const createResult = (await sendRequest('session/prompt', { - sessionId, - prompt: [ - { - type: 'text', - text: 'Call cron_create with cron expression "*/1 * * * *" and prompt "Say CRONFIRE7742 and nothing else" and recurring true. Confirm briefly.', - }, - ], - })) as { stopReason: string }; - expect(createResult.stopReason).toBe('end_turn'); + try { + const sessionId = await initSession(sendRequest, rig.testDir!); - const promptDoneAt = Date.now(); + // --- Part 1: Create a cron job that fires every minute --- + const createResult = (await sendRequest('session/prompt', { + sessionId, + prompt: [ + { + type: 'text', + text: 'Call cron_create with cron expression "*/1 * * * *" and prompt "Say CRONFIRE7742 and nothing else" and recurring true. Confirm briefly.', + }, + ], + })) as { stopReason: string }; + expect(createResult.stopReason).toBe('end_turn'); - // --- Part 2: Session stays responsive while cron is pending --- - const interactiveResult = (await sendRequest('session/prompt', { - sessionId, - prompt: [ - { - type: 'text', - text: 'Say INTERACTIVE8899 and nothing else.', - }, - ], - })) as { stopReason: string }; - expect(interactiveResult.stopReason).toBe('end_turn'); + // Fail fast if the cron_create tool call was not served to the first + // user prompt. An internal model call before the first prompt (title + // generation, a classifier pass) would shift dispatch and otherwise + // surface only as an opaque 75s timeout in Part 3. + expect( + JSON.stringify(fakeServer.requests[0]?.body['messages']), + 'requestIndex 0 was not the cron_create prompt — dispatch shifted', + ).toContain('CRONFIRE7742'); - // --- Part 3: Wait for cron-fired notification (up to 75s) --- - // The cron fires at the next minute boundary. The model response - // should stream back as sessionUpdate notifications after the - // originating prompt has already returned. + const promptDoneAt = Date.now(); - // 3a: Check for user_message_chunk echoing the cron prompt with _meta.source - const cronUserMsg = await waitForSessionUpdate( - (u) => - u.update?.sessionUpdate === 'user_message_chunk' && - (u.update?.content?.text ?? '').includes('CRONFIRE7742') && - u.receivedAt > promptDoneAt, - 'cron-fired user_message_chunk with CRONFIRE7742', - 75_000, - ); - expect(cronUserMsg.update?._meta).toBeDefined(); - expect(cronUserMsg.update?._meta?.source).toBe('cron'); + // --- Part 2: Session stays responsive while cron is pending --- + const interactiveResult = (await sendRequest('session/prompt', { + sessionId, + prompt: [ + { + type: 'text', + text: 'Say INTERACTIVE8899 and nothing else.', + }, + ], + })) as { stopReason: string }; + expect(interactiveResult.stopReason).toBe('end_turn'); - // 3b: Check for agent_message_chunk after the cron user message - // (the model's response to the cron prompt) - const cronAgentMsg = await waitForSessionUpdate( - (u) => - u.update?.sessionUpdate === 'agent_message_chunk' && - u.receivedAt > cronUserMsg.receivedAt, - 'agent_message_chunk after cron fire', - 15_000, // should already be here by now - ); - expect(cronAgentMsg.receivedAt).toBeGreaterThan(promptDoneAt); - } catch (e) { - if (stderr.length) console.error('Agent stderr:', stderr.join('')); - throw e; + // --- Part 3: Wait for cron-fired notification (up to 75s) --- + // The cron fires at the next minute boundary. The model response + // should stream back as sessionUpdate notifications after the + // originating prompt has already returned. + + // 3a: Check for user_message_chunk echoing the cron prompt with _meta.source + const cronUserMsg = await waitForSessionUpdate( + (u) => + u.update?.sessionUpdate === 'user_message_chunk' && + (u.update?.content?.text ?? '').includes('CRONFIRE7742') && + u.receivedAt > promptDoneAt, + 'cron-fired user_message_chunk with CRONFIRE7742', + 75_000, + ); + expect(cronUserMsg.update?._meta).toBeDefined(); + expect(cronUserMsg.update?._meta?.source).toBe('cron'); + + // 3b: Check for agent_message_chunk after the cron user message + // (the model's response to the cron prompt) + const cronAgentMsg = await waitForSessionUpdate( + (u) => + u.update?.sessionUpdate === 'agent_message_chunk' && + u.receivedAt > cronUserMsg.receivedAt, + 'agent_message_chunk after cron fire', + 15_000, // should already be here by now + ); + expect(cronAgentMsg.receivedAt).toBeGreaterThan(promptDoneAt); + } catch (e) { + if (stderr.length) console.error('Agent stderr:', stderr.join('')); + console.error( + 'Fake server requests:', + fakeServer.requests.map((r, i) => ({ + index: i, + model: r.body['model'], + messages: JSON.stringify(r.body['messages']).slice(0, 300), + })), + ); + throw e; + } finally { + await cleanup(); + } } finally { - await cleanup(); await fakeServer.close(); } },