fix(ci): stabilize merge queue checks (#6056)

This commit is contained in:
易良 2026-06-30 18:27:15 +08:00 committed by GitHub
parent f3694dde67
commit 7f34cdabe1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 274 additions and 122 deletions

View file

@ -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:

View file

@ -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;

View file

@ -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<unknown> = Promise.resolve();
@ -273,7 +275,6 @@ export function registerWorkspaceExtensionRoutes(
error?: string;
};
const extensionOperations = new Map<string, ExtensionOperationStatus>();
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;

View file

@ -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 () => {

View file

@ -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');
});

View file

@ -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).

View file

@ -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<typeof Storage.getGlobalQwenDir>;
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');
});

View file

@ -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<typeof Storage.getGlobalQwenDir>;
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 () => {

View file

@ -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

View file

@ -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'}
/>
</Box>

View file

@ -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('<AskUserQuestionDialog />', () => {
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, unmount } = renderWithProviders(
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
@ -421,11 +428,16 @@ describe('<AskUserQuestionDialog />', () => {
// 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('<AskUserQuestionDialog />', () => {
{ 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('<AskUserQuestionDialog />', () => {
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, unmount } = renderWithProviders(
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
@ -454,6 +466,9 @@ describe('<AskUserQuestionDialog />', () => {
// 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('<AskUserQuestionDialog />', () => {
expect(onConfirm).not.toHaveBeenCalled();
unmount();
});
}, 10000);
it('submits predefined options together with custom input', async () => {
const onConfirm = vi.fn();
@ -470,7 +485,7 @@ describe('<AskUserQuestionDialog />', () => {
questions: [createSingleQuestion({ multiSelect: true })],
});
const { stdin, unmount } = renderWithProviders(
const { stdin, lastFrame, unmount } = renderWithProviders(
<AskUserQuestionDialog
confirmationDetails={details}
onConfirm={onConfirm}
@ -480,15 +495,22 @@ describe('<AskUserQuestionDialog />', () => {
// 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('<AskUserQuestionDialog />', () => {
{ answers: { 0: expect.stringContaining('Purple') } },
);
unmount();
});
}, 10000);
});
describe('multiple questions', () => {

View file

@ -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<AskUserQuestionDialogProps> = ({
const [customInputValues, setCustomInputValues] = useState<
Record<number, string>
>({});
const customInputValuesRef = useRef<Record<number, string>>({});
const [selectedIndex, setSelectedIndex] = useState(0);
const [multiSelectedOptions, setMultiSelectedOptions] = useState<
Record<number, string[]>
@ -67,7 +68,9 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
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<AskUserQuestionDialogProps> = ({
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<AskUserQuestionDialogProps> = ({
}
};
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<AskUserQuestionDialogProps> = ({
[currentQuestionIndex]: trimmedValue.length > 0,
}));
if (trimmedValue) {
handleMultiSelectSubmit();
selectAndAdvance(getCurrentMultiSelectAnswer(true, inputValue)!);
}
return;
}
@ -472,7 +483,13 @@ export const AskUserQuestionDialog: React.FC<AskUserQuestionDialogProps> = ({
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,

View file

@ -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(

View file

@ -190,6 +190,9 @@ export class TeamManager {
/** Per-agent teammate identity for re-entering AsyncLocalStorage. */
private readonly agentIdentities = new Map<string, TeammateIdentity>();
/** Async coordination work kicked off from synchronous event emitters. */
private readonly pendingAsyncWork = new Set<Promise<unknown>>();
/** 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<unknown>): 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();

View file

@ -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,
);

View file

@ -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<typeof import('node:path')>();
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);

View file

@ -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
// `<homeQwenDir>/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';
}

View file

@ -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

View file

@ -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

View file

@ -68,6 +68,7 @@ const LINTERS = {
-ignore 'SC2002:' \
-ignore 'SC2016:' \
-ignore 'SC2129:' \
-ignore 'unexpected key "deployment" for "environment" section' \
-ignore 'label ".+" is unknown'
`,
},