From 7f34cdabe1e8273f393b90510bf23b98a657705c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=98=93=E8=89=AF?= <1204183885@qq.com> Date: Tue, 30 Jun 2026 18:27:15 +0800 Subject: [PATCH] fix(ci): stabilize merge queue checks (#6056) --- .github/workflows/ci.yml | 23 ++++++ .../acp-integration/session/Session.test.ts | 11 ++- .../src/serve/routes/workspace-extensions.ts | 5 +- .../routes/workspace-permissions.test.ts | 18 ++--- packages/cli/src/serve/server.test.ts | 37 +++++---- packages/cli/src/serve/server.ts | 4 + .../cli/src/serve/workspace-agents.test.ts | 78 ++++++++++--------- .../cli/src/serve/workspace-memory.test.ts | 44 +++++++---- packages/cli/src/serve/workspace-memory.ts | 4 +- .../cli/src/ui/auth/ProviderSetupSteps.tsx | 2 +- .../messages/AskUserQuestionDialog.test.tsx | 46 ++++++++--- .../messages/AskUserQuestionDialog.tsx | 43 ++++++---- .../src/ui/components/shared/TextInput.tsx | 4 +- packages/core/src/agents/team/TeamManager.ts | 13 +++- .../test-utils/coordination-harness.test.ts | 21 +++-- .../bundled/loop/loop-tick-resolver.test.ts | 30 ++++++- .../skills/bundled/loop/loop-tick-resolver.ts | 4 +- .../installation/install-qwen-standalone.sh | 4 +- .../installation/install-qwen-with-source.sh | 4 +- scripts/lint.js | 1 + 20 files changed, 274 insertions(+), 122 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fc32cc34ea..29ca6f1f42 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -118,6 +118,7 @@ jobs: # post-merge re-run on `main` would be redundant. if: "${{ !cancelled() && github.event_name != 'push' }}" runs-on: '${{ fromJSON(needs.classify_pr.outputs.ubuntu_runner || ''["ubuntu-latest"]'') }}' + timeout-minutes: 60 permissions: contents: 'read' checks: 'write' @@ -209,6 +210,7 @@ jobs: - name: 'Run actionlint' if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" + timeout-minutes: 5 run: 'node scripts/lint.js --actionlint' - name: 'Run shellcheck' @@ -256,6 +258,13 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" env: NO_COLOR: true + HOME: '${{ runner.temp }}/qwen-ci-home' + USERPROFILE: '${{ runner.temp }}/qwen-ci-home' + OPENAI_API_KEY: '' + DASHSCOPE_API_KEY: '' + QWEN_API_KEY: '' + GEMINI_API_KEY: '' + QWEN_DEFAULT_AUTH_TYPE: '' run: 'npm run test:ci' - name: 'Run no-AK integration smoke tests' @@ -338,6 +347,13 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" env: NO_COLOR: true + HOME: '${{ runner.temp }}/qwen-ci-home' + USERPROFILE: '${{ runner.temp }}/qwen-ci-home' + OPENAI_API_KEY: '' + DASHSCOPE_API_KEY: '' + QWEN_API_KEY: '' + GEMINI_API_KEY: '' + QWEN_DEFAULT_AUTH_TYPE: '' run: 'npm run test:ci' # Windows counterpart of test_macos (see that job's note). Runner is @@ -384,6 +400,13 @@ jobs: if: "${{ needs.classify_pr.outputs.skip_ci != 'true' }}" env: NO_COLOR: true + HOME: '${{ runner.temp }}/qwen-ci-home' + USERPROFILE: '${{ runner.temp }}/qwen-ci-home' + OPENAI_API_KEY: '' + DASHSCOPE_API_KEY: '' + QWEN_API_KEY: '' + GEMINI_API_KEY: '' + QWEN_DEFAULT_AUTH_TYPE: '' run: 'npm run test:ci' post_coverage_comment: diff --git a/packages/cli/src/acp-integration/session/Session.test.ts b/packages/cli/src/acp-integration/session/Session.test.ts index 9f977ff836..40549e9ddd 100644 --- a/packages/cli/src/acp-integration/session/Session.test.ts +++ b/packages/cli/src/acp-integration/session/Session.test.ts @@ -5131,9 +5131,14 @@ describe('Session', () => { prompt: [{ type: 'text', text: 'hello' }], }); - await vi.waitFor(() => { - expect(loopTickResolverDepsSpy).toHaveBeenCalled(); - }); + await vi.waitFor( + () => { + expect(loopTickResolverDepsSpy).toHaveBeenCalled(); + }, + { + timeout: 3000, + }, + ); const deps = loopTickResolverDepsSpy.mock.calls.at(-1)![0] as { homeDir: string; diff --git a/packages/cli/src/serve/routes/workspace-extensions.ts b/packages/cli/src/serve/routes/workspace-extensions.ts index 09fa4ccdd0..610a3fc1ed 100644 --- a/packages/cli/src/serve/routes/workspace-extensions.ts +++ b/packages/cli/src/serve/routes/workspace-extensions.ts @@ -45,6 +45,7 @@ interface RegisterWorkspaceExtensionRoutesDeps { mutate: (opts?: { strict?: boolean }) => RequestHandler; safeBody: SafeBody; sendBridgeError: SendBridgeError; + maxExtensionOperationHistory?: number; } export function registerWorkspaceExtensionRoutes( @@ -59,6 +60,7 @@ export function registerWorkspaceExtensionRoutes( safeBody, sendBridgeError, } = deps; + const maxExtensionOperationHistory = deps.maxExtensionOperationHistory ?? 100; const buildWorkspaceCtx = createBuildWorkspaceCtx(boundWorkspace); let extensionInstallQueue: Promise = Promise.resolve(); @@ -273,7 +275,6 @@ export function registerWorkspaceExtensionRoutes( error?: string; }; const extensionOperations = new Map(); - const MAX_EXTENSION_OPERATION_HISTORY = 100; const isTerminalExtensionOperation = ( operation: ExtensionOperationStatus, ): boolean => operation.status !== 'queued' && operation.status !== 'running'; @@ -287,7 +288,7 @@ export function registerWorkspaceExtensionRoutes( operation: ExtensionOperationStatus, ): void => { extensionOperations.set(operation.operationId, operation); - while (extensionOperations.size > MAX_EXTENSION_OPERATION_HISTORY) { + while (extensionOperations.size > maxExtensionOperationHistory) { let evicted = false; for (const [id, storedOperation] of extensionOperations) { if (!isTerminalExtensionOperation(storedOperation)) continue; diff --git a/packages/cli/src/serve/routes/workspace-permissions.test.ts b/packages/cli/src/serve/routes/workspace-permissions.test.ts index a1b2a80041..e446759bf7 100644 --- a/packages/cli/src/serve/routes/workspace-permissions.test.ts +++ b/packages/cli/src/serve/routes/workspace-permissions.test.ts @@ -345,6 +345,9 @@ describe('workspace permissions routes', () => { }); it('POST preserves already-stored malformed permission rules', async () => { + await teardown(h); + const live = vi.fn(); + h = await makeHarness({ setWorkspacePermissionRules: live }); const acpResponse = { v: 1, user: { @@ -358,7 +361,7 @@ describe('workspace permissions routes', () => { merged: { allow: ['Bash(git *', 'Bash(git status)'], ask: [], deny: [] }, isTrusted: true, }; - h.setWorkspacePermissionRules.mockResolvedValueOnce(acpResponse); + live.mockResolvedValueOnce(acpResponse); await writeJson(path.join(h.home, 'settings.json'), { permissions: { allow: ['Bash(git *'], @@ -374,14 +377,11 @@ describe('workspace permissions routes', () => { }); expect(res.status).toBe(200); - expect(h.setWorkspacePermissionRules).toHaveBeenCalledWith( - expect.any(Object), - { - scope: 'user', - ruleType: 'allow', - rules: ['Bash(git *', 'Bash(git status)'], - }, - ); + expect(live).toHaveBeenCalledWith(expect.any(Object), { + scope: 'user', + ruleType: 'allow', + rules: ['Bash(git *', 'Bash(git status)'], + }); }); it('POST still rejects newly malformed permission rules', async () => { diff --git a/packages/cli/src/serve/server.test.ts b/packages/cli/src/serve/server.test.ts index e361df9e7e..6b568bedae 100644 --- a/packages/cli/src/serve/server.test.ts +++ b/packages/cli/src/serve/server.test.ts @@ -3241,14 +3241,15 @@ describe('createServeApp', () => { try { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; const bridge = fakeBridge({ knownClientIds: ['client-1'] }); + const maxExtensionOperationHistory = 3; const app = createServeApp( { ...tokenOpts, workspace: WS_BOUND }, undefined, - { bridge }, + { bridge, maxExtensionOperationHistory }, ); const operationIds: string[] = []; - for (let i = 0; i < 101; i += 1) { + for (let i = 0; i <= maxExtensionOperationHistory; i += 1) { const res = await request(app) .post('/workspace/extensions/install') .set('Host', `127.0.0.1:${tokenOpts.port}`) @@ -3296,7 +3297,7 @@ describe('createServeApp', () => { } finally { restore(); } - }); + }, 15_000); it('returns 404 for unknown extension operation ids', async () => { const tokenOpts: ServeOptions = { ...baseOpts, token: 'secret' }; @@ -8328,12 +8329,17 @@ describe('createServeApp', () => { expect(bridge.removeRuntimeMcpServerCalls).toHaveLength(0); }); - it('400 invalid_server_name when name is a reserved JS property', async () => { - for (const name of ['__proto__', 'constructor', 'prototype']) { + it.each([ + ['__proto__', '%5F%5Fproto%5F%5F'], + ['constructor', 'constructor'], + ['prototype', 'prototype'], + ] as const)( + '400 invalid_server_name when name is a reserved JS property: %s', + async (_name, pathSegment) => { const bridge = fakeBridge({ knownClientIds: ['client-1'] }); const app = createServeApp(tokenOpts, undefined, { bridge }); const res = await auth( - request(app).delete(`/workspace/mcp/servers/${name}`), + request(app).delete(`/workspace/mcp/servers/${pathSegment}`), ) .set('X-Qwen-Client-Id', 'client-1') .send(); @@ -8341,8 +8347,8 @@ describe('createServeApp', () => { expect(res.body.code).toBe('invalid_server_name'); expect(res.body.error).toContain('reserved'); expect(bridge.removeRuntimeMcpServerCalls).toHaveLength(0); - } - }); + }, + ); it('401 auth_required when no bearer token (strict gate)', async () => { const bridge = fakeBridge(); @@ -8800,7 +8806,8 @@ describe('createServeApp', () => { const res = await request(app) .post('/session/session-A/cancel') .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('X-Qwen-Client-Id', 'client-1'); + .set('X-Qwen-Client-Id', 'client-1') + .send({}); expect(res.status).toBe(204); expect(bridge.cancelCalls[0]?.context).toEqual({ clientId: 'client-1', @@ -8838,7 +8845,8 @@ describe('createServeApp', () => { const app = createServeApp(baseOpts, undefined, { bridge }); const res = await request(app) .delete('/session/session-A') - .set('Host', `127.0.0.1:${baseOpts.port}`); + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); expect(res.status).toBe(204); expect(bridge.closeCalls).toHaveLength(1); expect(bridge.closeCalls[0]?.sessionId).toBe('session-A'); @@ -8850,7 +8858,8 @@ describe('createServeApp', () => { const res = await request(app) .delete('/session/session-A') .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('X-Qwen-Client-Id', 'client-1'); + .set('X-Qwen-Client-Id', 'client-1') + .send(); expect(res.status).toBe(204); expect(bridge.closeCalls[0]?.context).toEqual({ clientId: 'client-1', @@ -8866,7 +8875,8 @@ describe('createServeApp', () => { const app = createServeApp(baseOpts, undefined, { bridge }); const res = await request(app) .delete('/session/missing') - .set('Host', `127.0.0.1:${baseOpts.port}`); + .set('Host', `127.0.0.1:${baseOpts.port}`) + .send(); expect(res.status).toBe(404); expect(res.body.sessionId).toBe('missing'); }); @@ -8881,7 +8891,8 @@ describe('createServeApp', () => { const res = await request(app) .delete('/session/session-A') .set('Host', `127.0.0.1:${baseOpts.port}`) - .set('X-Qwen-Client-Id', 'bad-client'); + .set('X-Qwen-Client-Id', 'bad-client') + .send(); expect(res.status).toBe(400); expect(res.body.code).toBe('invalid_client_id'); }); diff --git a/packages/cli/src/serve/server.ts b/packages/cli/src/serve/server.ts index b326315ed6..2a894c83fd 100644 --- a/packages/cli/src/serve/server.ts +++ b/packages/cli/src/serve/server.ts @@ -178,6 +178,7 @@ export interface ServeAppDeps { * and a stderr audit sink. */ deviceFlowRegistry?: DeviceFlowRegistry; + maxExtensionOperationHistory?: number; /** * Extra device-flow providers for tests / future extensions. * Production builds register only `QwenOAuthDeviceFlowProvider`; @@ -599,6 +600,9 @@ export function createServeApp( mutate, safeBody, sendBridgeError, + ...(deps.maxExtensionOperationHistory === undefined + ? {} + : { maxExtensionOperationHistory: deps.maxExtensionOperationHistory }), }); // Workspace file routes (read-only + mutation). diff --git a/packages/cli/src/serve/workspace-agents.test.ts b/packages/cli/src/serve/workspace-agents.test.ts index d5d1798133..aaa4834705 100644 --- a/packages/cli/src/serve/workspace-agents.test.ts +++ b/packages/cli/src/serve/workspace-agents.test.ts @@ -18,7 +18,7 @@ import { vi, type MockInstance, } from 'vitest'; -import { Storage, QWEN_DIR } from '@qwen-code/qwen-code-core'; +import { QWEN_DIR, Storage } from '@qwen-code/qwen-code-core'; import { createMutationGate } from './auth.js'; import type { AcpSessionBridge } from './acp-session-bridge.js'; import type { BridgeEvent } from '@qwen-code/acp-bridge/eventBus'; @@ -114,7 +114,7 @@ function buildApp(opts: { const app = express(); app.use(express.json({ limit: '10mb' })); const mutate = createMutationGate({ - tokenConfigured: !opts.strictNoToken, + tokenConfigured: opts.strictNoToken !== true, requireAuth: false, }); mountWorkspaceAgentsRoutes(app, { @@ -155,7 +155,7 @@ describe('workspace agents routes', () => { let tmp: string; let workspace: string; let globalDir: string; - let getGlobalQwenDirSpy: MockInstance<() => string>; + let getGlobalQwenDirSpy: MockInstance; beforeEach(async () => { tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-agents-')); @@ -173,6 +173,10 @@ describe('workspace agents routes', () => { await fs.rm(tmp, { recursive: true, force: true }); }); + function missingAgentName(prefix = 'missing-agent') { + return `${prefix}-${path.basename(tmp)}`; + } + it('lists built-in agents alongside on-disk project agents', async () => { const projectAgentsDir = path.join(workspace, QWEN_DIR, 'agents'); await fs.mkdir(projectAgentsDir, { recursive: true }); @@ -241,18 +245,27 @@ describe('workspace agents routes', () => { it('returns the full detail (with systemPrompt) on GET /workspace/agents/:agentType', async () => { const bridge = buildBridgeStub(); const app = buildApp({ bridge, boundWorkspace: workspace }); - const res = await request(app).get('/workspace/agents/general-purpose'); + const create = await request(app).post('/workspace/agents').send({ + name: 'detail-agent', + description: 'detail agent description', + systemPrompt: 'you are the detail agent', + scope: 'workspace', + }); + expect(create.status).toBe(201); + + const res = await request(app).get('/workspace/agents/detail-agent'); expect(res.status).toBe(200); - expect(res.body.name).toBe('general-purpose'); - expect(typeof res.body.systemPrompt).toBe('string'); - expect(res.body.isBuiltin).toBe(true); - expect(res.body.level).toBe('builtin'); + expect(res.body.name).toBe('detail-agent'); + expect(res.body.systemPrompt).toBe('you are the detail agent'); + expect(res.body.isBuiltin).toBe(false); + expect(res.body.level).toBe('project'); }); it('returns 404 agent_not_found for unknown agent', async () => { const bridge = buildBridgeStub(); const app = buildApp({ bridge, boundWorkspace: workspace }); - const res = await request(app).get('/workspace/agents/no-such-agent'); + const name = missingAgentName(); + const res = await request(app).get(`/workspace/agents/${name}`); expect(res.status).toBe(404); expect(res.body.code).toBe('agent_not_found'); }); @@ -340,31 +353,22 @@ describe('workspace agents routes', () => { expect(second.body.code).toBe('agent_already_exists'); }); - it('rejects 422 invalid_config when create uses a builtin agent name', async () => { - const bridge = buildBridgeStub(); - const app = buildApp({ bridge, boundWorkspace: workspace }); - const res = await request(app).post('/workspace/agents').send({ - name: 'general-purpose', - description: 'a description longer than ten chars', - systemPrompt: 'this is a system prompt', - scope: 'workspace', - }); - expect(res.status).toBe(422); - expect(res.body.code).toBe('invalid_config'); - expect(res.body.error).toMatch(/built-in/i); - - // BuiltinAgentRegistry.isBuiltinAgent is case-insensitive — both - // `Explore` and `explore` must reject so a project-level shadow - // can never land regardless of how the client cases the name. - const res2 = await request(app).post('/workspace/agents').send({ - name: 'explore', - description: 'a description longer than ten chars', - systemPrompt: 'this is a system prompt', - scope: 'workspace', - }); - expect(res2.status).toBe(422); - expect(res2.body.code).toBe('invalid_config'); - }); + it.each(['general-purpose', 'explore'])( + 'rejects 422 invalid_config when create uses builtin agent name %s', + async (name) => { + const bridge = buildBridgeStub(); + const app = buildApp({ bridge, boundWorkspace: workspace }); + const res = await request(app).post('/workspace/agents').send({ + name, + description: 'a description longer than ten chars', + systemPrompt: 'this is a system prompt', + scope: 'workspace', + }); + expect(res.status).toBe(422); + expect(res.body.code).toBe('invalid_config'); + expect(res.body.error).toMatch(/built-in/i); + }, + ); it('returns 422 invalid_config for missing required fields', async () => { const bridge = buildBridgeStub(); @@ -416,8 +420,9 @@ describe('workspace agents routes', () => { it('returns 404 agent_not_found when updating an unknown agent', async () => { const bridge = buildBridgeStub(); const app = buildApp({ bridge, boundWorkspace: workspace }); + const name = missingAgentName(); const res = await request(app) - .post('/workspace/agents/no-such-agent') + .post(`/workspace/agents/${name}`) .send({ description: 'x' }); expect(res.status).toBe(404); expect(res.body.code).toBe('agent_not_found'); @@ -464,7 +469,8 @@ describe('workspace agents routes', () => { it('returns 404 when deleting a missing agent', async () => { const bridge = buildBridgeStub(); const app = buildApp({ bridge, boundWorkspace: workspace }); - const res = await request(app).delete('/workspace/agents/no-such-agent'); + const name = missingAgentName(); + const res = await request(app).delete(`/workspace/agents/${name}`); expect(res.status).toBe(404); expect(res.body.code).toBe('agent_not_found'); }); diff --git a/packages/cli/src/serve/workspace-memory.test.ts b/packages/cli/src/serve/workspace-memory.test.ts index 68d1b16a9c..c071e2aa71 100644 --- a/packages/cli/src/serve/workspace-memory.test.ts +++ b/packages/cli/src/serve/workspace-memory.test.ts @@ -18,7 +18,12 @@ import { vi, type MockInstance, } from 'vitest'; -import { Storage } from '@qwen-code/qwen-code-core'; +import { + AGENT_CONTEXT_FILENAME, + DEFAULT_CONTEXT_FILENAME, + Storage, + setGeminiMdFilename, +} from '@qwen-code/qwen-code-core'; import { createMutationGate } from './auth.js'; import { InvalidClientIdError, @@ -115,16 +120,20 @@ function buildApp(opts: { bridge: AcpSessionBridge; boundWorkspace: string; strictNoToken?: boolean; + collectStatus?: Parameters< + typeof mountWorkspaceMemoryRoutes + >[1]['collectStatus']; }) { const app = express(); app.use(express.json({ limit: '10mb' })); const mutate = createMutationGate({ - tokenConfigured: !opts.strictNoToken, + tokenConfigured: opts.strictNoToken !== true, requireAuth: false, }); mountWorkspaceMemoryRoutes(app, { bridge: opts.bridge, boundWorkspace: opts.boundWorkspace, + ...(opts.collectStatus ? { collectStatus: opts.collectStatus } : {}), mutate, parseClientId: (req, res) => { const raw = req.get('x-qwen-client-id'); @@ -156,11 +165,15 @@ function buildApp(opts: { return app; } +function resetContextFilenames(): void { + setGeminiMdFilename([DEFAULT_CONTEXT_FILENAME, AGENT_CONTEXT_FILENAME]); +} + describe('workspace memory routes', () => { let tmp: string; let workspace: string; let globalDir: string; - let getGlobalQwenDirSpy: MockInstance<() => string>; + let getGlobalQwenDirSpy: MockInstance; beforeEach(async () => { tmp = await fs.mkdtemp(path.join(os.tmpdir(), 'qwen-serve-memory-')); @@ -170,9 +183,11 @@ describe('workspace memory routes', () => { getGlobalQwenDirSpy = vi .spyOn(Storage, 'getGlobalQwenDir') .mockReturnValue(globalDir); + resetContextFilenames(); }); afterEach(async () => { + resetContextFilenames(); getGlobalQwenDirSpy.mockRestore(); await fs.rm(tmp, { recursive: true, force: true }); }); @@ -443,21 +458,16 @@ describe('workspace memory routes', () => { it('returns 500 memory_discovery_failed when GET helper throws unexpectedly', async () => { const bridge = buildBridgeStub(); - const app = buildApp({ bridge, boundWorkspace: workspace }); - // Force the helper to throw by spying on `Storage.getGlobalQwenDir` - // — every call site of the discovery walk uses it. - const failGlobal = vi - .spyOn(Storage, 'getGlobalQwenDir') - .mockImplementation(() => { + const app = buildApp({ + bridge, + boundWorkspace: workspace, + collectStatus: async () => { throw new Error('boom'); - }); - try { - const res = await request(app).get('/workspace/memory'); - expect(res.status).toBe(500); - expect(res.body.code).toBe('memory_discovery_failed'); - } finally { - failGlobal.mockRestore(); - } + }, + }); + const res = await request(app).get('/workspace/memory'); + expect(res.status).toBe(500); + expect(res.body.code).toBe('memory_discovery_failed'); }); it('stamps originatorClientId on the memory_changed event for known clients', async () => { diff --git a/packages/cli/src/serve/workspace-memory.ts b/packages/cli/src/serve/workspace-memory.ts index 4ccf133a3c..f1590b08b2 100644 --- a/packages/cli/src/serve/workspace-memory.ts +++ b/packages/cli/src/serve/workspace-memory.ts @@ -59,6 +59,7 @@ import { export interface WorkspaceMemoryRouteDeps { bridge: AcpSessionBridge; boundWorkspace: string; + collectStatus?: typeof collectWorkspaceMemoryStatus; /** * `mutate({ strict: true })`-style middleware factory from PR 15. * Passed in so `server.ts` stays the single composition root for @@ -85,7 +86,8 @@ export function mountWorkspaceMemoryRoutes( ): void { app.get('/workspace/memory', async (_req, res) => { try { - const status = await collectWorkspaceMemoryStatus(deps.boundWorkspace); + const collectStatus = deps.collectStatus ?? collectWorkspaceMemoryStatus; + const status = await collectStatus(deps.boundWorkspace); res.status(200).json(status); } catch (err) { // Per-file stat failures are caught inside diff --git a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx index 6f0660ba9e..296d691a68 100644 --- a/packages/cli/src/ui/auth/ProviderSetupSteps.tsx +++ b/packages/cli/src/ui/auth/ProviderSetupSteps.tsx @@ -523,7 +523,7 @@ function ModelIdsStep({ key="model-ids-input" value={flow.state.modelIds} onChange={flow.changeModelIds} - onSubmit={flow.submitModelIds} + onSubmit={() => flow.submitModelIds()} placeholder={defaultIds || 'model-id-1, model-id-2'} /> diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx index 487b2d2f50..e08feeb72d 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.test.tsx @@ -12,12 +12,19 @@ import { renderWithProviders } from '../../../test-utils/render.js'; import stripAnsi from 'strip-ansi'; const wait = (ms = 50) => new Promise((resolve) => setTimeout(resolve, ms)); +const writeText = async ( + stdin: { write: (input: string) => unknown }, + text: string, +) => { + stdin.write(text); + await wait(); +}; const clean = (value: string | undefined) => stripAnsi(value ?? ''); const waitForFrame = async ( predicate: () => void, options: { timeout?: number; interval?: number } = {}, ) => { - const { timeout = 1000, interval = 10 } = options; + const { timeout = 3000, interval = 10 } = options; const start = Date.now(); let lastError: unknown; @@ -411,7 +418,7 @@ describe('', () => { questions: [createSingleQuestion({ multiSelect: true })], }); - const { stdin, unmount } = renderWithProviders( + const { stdin, lastFrame, unmount } = renderWithProviders( ', () => { // Navigate to "Type something..." option (index 3, after 3 predefined options) stdin.write('4'); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('❯ [ ] 4.'); + }); await wait(); // Type custom text - stdin.write('My custom answer'); - await wait(); + await writeText(stdin, 'My custom answer'); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('My custom answer'); + }); // Press Enter — should auto-select and submit stdin.write('\r'); @@ -436,7 +448,7 @@ describe('', () => { { answers: { 0: 'My custom answer' } }, ); unmount(); - }); + }, 10000); it('does not submit when Enter pressed on empty custom input', async () => { const onConfirm = vi.fn(); @@ -444,7 +456,7 @@ describe('', () => { questions: [createSingleQuestion({ multiSelect: true })], }); - const { stdin, unmount } = renderWithProviders( + const { stdin, lastFrame, unmount } = renderWithProviders( ', () => { // Navigate to "Type something..." option stdin.write('4'); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('❯ [ ] 4.'); + }); await wait(); // Press Enter without typing anything — should NOT submit @@ -462,7 +477,7 @@ describe('', () => { expect(onConfirm).not.toHaveBeenCalled(); unmount(); - }); + }, 10000); it('submits predefined options together with custom input', async () => { const onConfirm = vi.fn(); @@ -470,7 +485,7 @@ describe('', () => { questions: [createSingleQuestion({ multiSelect: true })], }); - const { stdin, unmount } = renderWithProviders( + const { stdin, lastFrame, unmount } = renderWithProviders( ', () => { // Space to toggle first option (Red) stdin.write(' '); - await wait(); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('[✓] 1. Red'); + }); // Navigate to "Type something..." option stdin.write('4'); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('❯ [ ] 4.'); + }); await wait(); // Type custom text - stdin.write('Purple'); - await wait(); + await writeText(stdin, 'Purple'); + await waitForFrame(() => { + expect(clean(lastFrame())).toContain('Purple'); + }); // Press Enter — should submit both Red and Purple stdin.write('\r'); @@ -503,7 +525,7 @@ describe('', () => { { answers: { 0: expect.stringContaining('Purple') } }, ); unmount(); - }); + }, 10000); }); describe('multiple questions', () => { diff --git a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx index cb2173af5c..11f5b19483 100644 --- a/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx +++ b/packages/cli/src/ui/components/messages/AskUserQuestionDialog.tsx @@ -5,7 +5,7 @@ */ import type React from 'react'; -import { useState } from 'react'; +import { useRef, useState } from 'react'; import { Box, Text } from 'ink'; import { type ToolAskUserQuestionConfirmationDetails, @@ -39,6 +39,7 @@ export const AskUserQuestionDialog: React.FC = ({ const [customInputValues, setCustomInputValues] = useState< Record >({}); + const customInputValuesRef = useRef>({}); const [selectedIndex, setSelectedIndex] = useState(0); const [multiSelectedOptions, setMultiSelectedOptions] = useState< Record @@ -67,7 +68,9 @@ export const AskUserQuestionDialog: React.FC = ({ currentQuestion && selectedIndex === currentQuestion.options.length; - const currentCustomInputValue = customInputValues[currentQuestionIndex] ?? ''; + const getCustomInputValue = (idx: number) => + customInputValuesRef.current[idx] ?? customInputValues[idx] ?? ''; + const currentCustomInputValue = getCustomInputValue(currentQuestionIndex); const isCustomInputAnswer = !isSubmitTab && currentQuestion && @@ -82,7 +85,7 @@ export const AskUserQuestionDialog: React.FC = ({ const q = confirmationDetails.questions[idx]; if (q?.multiSelect) { const selections = [...(multiSelectedOptions[idx] ?? [])]; - const customValue = (customInputValues[idx] ?? '').trim(); + const customValue = getCustomInputValue(idx).trim(); if (customInputChecked[idx] && customValue) { selections.push(customValue); } @@ -119,20 +122,28 @@ export const AskUserQuestionDialog: React.FC = ({ } }; - const handleMultiSelectSubmit = () => { + const getCurrentMultiSelectAnswer = ( + includeCustomInput = customInputChecked[currentQuestionIndex], + inputValue = currentCustomInputValue, + ): string | undefined => { if (!currentQuestion) return; const selections = [...(multiSelectedOptions[currentQuestionIndex] ?? [])]; - const customValue = currentCustomInputValue.trim(); - if (customInputChecked[currentQuestionIndex] && customValue) { + const customValue = inputValue.trim(); + if (includeCustomInput && customValue) { selections.push(customValue); } - if (selections.length === 0) return; - - selectAndAdvance(selections.join(', ')); + return selections.length > 0 ? selections.join(', ') : undefined; }; - const handleCustomInputSubmit = () => { - const trimmedValue = currentCustomInputValue.trim(); + const handleMultiSelectSubmit = () => { + const answer = getCurrentMultiSelectAnswer(); + if (!answer) return; + + selectAndAdvance(answer); + }; + + const handleCustomInputSubmit = (inputValue = currentCustomInputValue) => { + const trimmedValue = inputValue.trim(); if (isMultiSelect) { // Toggle custom input checked state, then submit/advance if non-empty @@ -141,7 +152,7 @@ export const AskUserQuestionDialog: React.FC = ({ [currentQuestionIndex]: trimmedValue.length > 0, })); if (trimmedValue) { - handleMultiSelectSubmit(); + selectAndAdvance(getCurrentMultiSelectAnswer(true, inputValue)!); } return; } @@ -472,7 +483,13 @@ export const AskUserQuestionDialog: React.FC = ({ initialCursorOffset={currentCustomInputValue.length} onChange={(value: string) => { const oldValue = - customInputValues[currentQuestionIndex] ?? ''; + customInputValuesRef.current[currentQuestionIndex] ?? + customInputValues[currentQuestionIndex] ?? + ''; + customInputValuesRef.current = { + ...customInputValuesRef.current, + [currentQuestionIndex]: value, + }; if (isMultiSelect && value !== oldValue) { setCustomInputChecked((prevChecked) => ({ ...prevChecked, diff --git a/packages/cli/src/ui/components/shared/TextInput.tsx b/packages/cli/src/ui/components/shared/TextInput.tsx index 0c9cd73284..5a4811637e 100644 --- a/packages/cli/src/ui/components/shared/TextInput.tsx +++ b/packages/cli/src/ui/components/shared/TextInput.tsx @@ -20,7 +20,7 @@ import { renderSoftwareCursor } from '../../utils/software-cursor.js'; export interface TextInputProps { value: string; onChange: (text: string) => void; - onSubmit?: () => void; + onSubmit?: (text: string) => void; /** Called when Tab is pressed; if provided, prevents the default tab-insertion behaviour. */ onTab?: (key: Key) => void; /** Called when ↑ is pressed; if provided, prevents cursor-up in the buffer. */ @@ -106,7 +106,7 @@ export function TextInput({ const handleSubmit = () => { if (!onSubmit) return; - onSubmit(); + onSubmit(buffer.text); }; useKeypress( diff --git a/packages/core/src/agents/team/TeamManager.ts b/packages/core/src/agents/team/TeamManager.ts index 4b34a9b71b..c75de8f957 100644 --- a/packages/core/src/agents/team/TeamManager.ts +++ b/packages/core/src/agents/team/TeamManager.ts @@ -190,6 +190,9 @@ export class TeamManager { /** Per-agent teammate identity for re-entering AsyncLocalStorage. */ private readonly agentIdentities = new Map(); + /** Async coordination work kicked off from synchronous event emitters. */ + private readonly pendingAsyncWork = new Set>(); + /** Optional subagent manager for loading specialized agent configs. */ private readonly subagentManager: SubagentManager | null; @@ -1134,7 +1137,7 @@ export class TeamManager { * least observable to the leader driving the team. */ private fireAndForget(label: string, work: Promise): void { - void work.catch((err) => { + const tracked = work.catch((err) => { const msg = err instanceof Error ? err.message : String(err); debug.warn(`${label} failed: ${msg}`); // Guarded: the callback can be detached during teardown / manager @@ -1152,6 +1155,10 @@ export class TeamManager { debug.warn(`${label}: leader message callback threw: ${cbMsg}`); } }); + this.pendingAsyncWork.add(tracked); + void tracked.finally(() => { + this.pendingAsyncWork.delete(tracked); + }); } // ─── Cleanup ──────────────────────────────────────────── @@ -1167,6 +1174,10 @@ export class TeamManager { } this.eventBridgeCleanups.clear(); + while (this.pendingAsyncWork.size > 0) { + await Promise.allSettled([...this.pendingAsyncWork]); + } + this.pendingMessages.clear(); this.lastActivityAt.clear(); this.agentIdentities.clear(); diff --git a/packages/core/src/agents/team/test-utils/coordination-harness.test.ts b/packages/core/src/agents/team/test-utils/coordination-harness.test.ts index 829ea1271d..0ae0ec4034 100644 --- a/packages/core/src/agents/team/test-utils/coordination-harness.test.ts +++ b/packages/core/src/agents/team/test-utils/coordination-harness.test.ts @@ -9,7 +9,8 @@ import * as path from 'node:path'; import { describe, it, expect, vi, afterEach } from 'vitest'; import { AgentStatus } from '../../runtime/agent-types.js'; import { TeamCoordinationHarness } from './coordination-harness.js'; -import { createTask } from '../tasks.js'; +import type { FakeAgent } from './fake-agent.js'; +import { createTask, listTasks } from '../tasks.js'; import { sendStructuredMessage, readInbox, getInboxPath } from '../mailbox.js'; // Mock Storage so all file I/O uses the harness's temp dir. @@ -416,7 +417,7 @@ describe('TeamCoordinationHarness', () => { const h = await createHarness(); // Spawn 5 workers that stay running on message. - const workers = []; + const workers: FakeAgent[] = []; for (let i = 0; i < 5; i++) { const w = await h.spawnTeammate(`worker-${i}`, { onMessage: () => 'stay_running', @@ -446,10 +447,20 @@ describe('TeamCoordinationHarness', () => { w.goIdle(); } - // Wait for the dust to settle. - await new Promise((r) => setTimeout(r, 200)); + await vi.waitFor(() => { + const claimers = workers.filter( + (w) => w.getReceivedMessages().length > 1, + ); + expect(claimers.length).toBe(1); + }); + await vi.waitFor(async () => { + const claimedTasks = await listTasks(h.teamName, { + status: 'in_progress', + }); + expect(claimedTasks).toHaveLength(1); + expect(claimedTasks[0]!.owner).toMatch(/^worker-\d$/); + }); - // Exactly one worker should have received the task. const claimers = workers.filter( (w) => w.getReceivedMessages().length > 1, ); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts index 9a3354d494..0a54fdb293 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.test.ts @@ -548,7 +548,7 @@ describe('LoopTickResolver', () => { homeQwenDir: root, allowProjectFile: () => true, }); - expect(atRoot.homeLoopLabel()).toBe(`$QWEN_HOME${path.sep}loop.md`); + expect(atRoot.homeLoopLabel()).toBe('$QWEN_HOME/loop.md'); // The garbled, separator-less form must never appear. expect(atRoot.homeLoopLabel()).not.toContain('$QWEN_HOMEloop.md'); } finally { @@ -557,6 +557,34 @@ describe('LoopTickResolver', () => { } }); + it('homeLoopLabel uses a forward slash in the $QWEN_HOME label even with Windows separators', async () => { + const prevQwenHome = process.env['QWEN_HOME']; + process.env['QWEN_HOME'] = 'C:\\qwen'; + vi.resetModules(); + vi.doMock('node:path', async (importActual) => { + const actual = await importActual(); + return { ...actual, sep: '\\' }; + }); + try { + const { LoopTickResolver: WindowsPathResolver } = await import( + './loop-tick-resolver.js' + ); + const windowsPathResolver = new WindowsPathResolver({ + projectRoot: 'C:\\project', + homeDir: 'C:\\qwen', + homeQwenDir: 'C:\\qwen', + allowProjectFile: () => true, + }); + + expect(windowsPathResolver.homeLoopLabel()).toBe('$QWEN_HOME/loop.md'); + } finally { + vi.doUnmock('node:path'); + vi.resetModules(); + if (prevQwenHome === undefined) delete process.env['QWEN_HOME']; + else process.env['QWEN_HOME'] = prevQwenHome; + } + }); + it('re-expands after delete→recreate even when the recreated content is identical', async () => { await writeProject('- same tasks'); expect((await resolver.resolve('dynamic')).full).toBe(true); diff --git a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts index 28f6446923..b1cb2d6b3a 100644 --- a/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts +++ b/packages/core/src/skills/bundled/loop/loop-tick-resolver.ts @@ -219,13 +219,13 @@ export class LoopTickResolver { // global dir (homeQwenDir is its resolved value), report the literal env-var // name — never the absolute path. The home candidate is always // `/loop.md`, so swap the whole resolved dir for `$QWEN_HOME` and - // re-attach the separator + basename directly. Deriving the tail from the + // re-attach the user-facing POSIX slash + basename directly. Deriving the tail from the // resolved path's length instead mishandles edge dirs: a trailing slash // (`$QWEN_HOME=/x/.qwen/`) over-counts the separator, and a filesystem-root // homeQwenDir (`$QWEN_HOME=/` → homeLoopPath `/loop.md`, dirname `/`) drops the // leading separator — both garbling the tail into `$QWEN_HOMEloop.md`. if (process.env['QWEN_HOME']) { - return `$QWEN_HOME${path.sep}loop.md`; + return '$QWEN_HOME/loop.md'; } return 'the configured global loop.md'; } diff --git a/scripts/installation/install-qwen-standalone.sh b/scripts/installation/install-qwen-standalone.sh index 82b7a7bbec..a3ec887a86 100644 --- a/scripts/installation/install-qwen-standalone.sh +++ b/scripts/installation/install-qwen-standalone.sh @@ -890,12 +890,12 @@ sha256_file() { local file_path="$1" if command_exists sha256sum; then - sha256sum "${file_path}" | awk '{print $1}' + LC_ALL=C LANG=C sha256sum "${file_path}" | awk '{print $1}' return 0 fi if command_exists shasum; then - shasum -a 256 "${file_path}" | awk '{print $1}' + LC_ALL=C LANG=C shasum -a 256 "${file_path}" | awk '{print $1}' return 0 fi diff --git a/scripts/installation/install-qwen-with-source.sh b/scripts/installation/install-qwen-with-source.sh index f07dea9d43..0534856a5b 100755 --- a/scripts/installation/install-qwen-with-source.sh +++ b/scripts/installation/install-qwen-with-source.sh @@ -538,12 +538,12 @@ sha256_file() { local file_path="$1" if command_exists sha256sum; then - sha256sum "${file_path}" | awk '{print $1}' + LC_ALL=C LANG=C sha256sum "${file_path}" | awk '{print $1}' return 0 fi if command_exists shasum; then - shasum -a 256 "${file_path}" | awk '{print $1}' + LC_ALL=C LANG=C shasum -a 256 "${file_path}" | awk '{print $1}' return 0 fi diff --git a/scripts/lint.js b/scripts/lint.js index f02b845f76..6c899ec265 100644 --- a/scripts/lint.js +++ b/scripts/lint.js @@ -68,6 +68,7 @@ const LINTERS = { -ignore 'SC2002:' \ -ignore 'SC2016:' \ -ignore 'SC2129:' \ + -ignore 'unexpected key "deployment" for "environment" section' \ -ignore 'label ".+" is unknown' `, },