mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
test(managed-agent): cover pending sibling approval holds (#15994)
This commit is contained in:
1 parent
e777fc7502
commit
f9124d6b1d
4 files changed
+286
No files matched your search
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { approvalHoldFixtures } from '../test-utils/hosted-approval-hold.js';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubEnv('QWEN_E2E_HOLD_PENDING_SIBLING_APPROVAL', 'approval-hold-session');
|
||||
});
|
||||
|
||||
it('releases a hold already aborted during its durable commit without a reason', async () => {
|
||||
const fixture = await approvalHoldFixtures()();
|
||||
const commit = fixture.harness.commitDurableWait.bind(fixture.harness);
|
||||
vi.spyOn(fixture.harness, 'commitDurableWait').mockImplementationOnce(
|
||||
async (...args) => {
|
||||
const boundary = await commit(...args);
|
||||
fixture.controller.abort(null);
|
||||
return boundary;
|
||||
},
|
||||
);
|
||||
const outcome = fixture.run();
|
||||
await vi.waitFor(() => expect(outcome.settled).toBe(true), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(fixture.holds()).toBe(1);
|
||||
expect(outcome.error).toBeInstanceOf(Error);
|
||||
expect((outcome.error as Error).cause).toEqual(new Error('aborted'));
|
||||
expect(fixture.wait).not.toHaveBeenCalled();
|
||||
expect(fixture.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { approvalHoldFixtures } from '../test-utils/hosted-approval-hold.js';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubEnv('QWEN_E2E_HOLD_PENDING_SIBLING_APPROVAL', undefined);
|
||||
});
|
||||
|
||||
it('continues through approval when the hold env is unset', async () => {
|
||||
const fixture = await approvalHoldFixtures()();
|
||||
const outcome = fixture.run();
|
||||
await fixture.deny();
|
||||
await vi.waitFor(() => expect(outcome.settled).toBe(true), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(outcome.error).toBeUndefined();
|
||||
expect(outcome.parts?.[0].functionResponse?.response?.['error']).toContain(
|
||||
'denied',
|
||||
);
|
||||
expect(fixture.holds()).toBe(0);
|
||||
expect(fixture.prepare).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { expect, it, vi } from 'vitest';
|
||||
import { approvalHoldFixtures } from '../test-utils/hosted-approval-hold.js';
|
||||
|
||||
vi.hoisted(() => {
|
||||
vi.stubEnv('QWEN_E2E_HOLD_PENDING_SIBLING_APPROVAL', 'approval-hold-session');
|
||||
});
|
||||
|
||||
it('holds only the matching Session once and releases on abort with its reason', async () => {
|
||||
const create = approvalHoldFixtures();
|
||||
const other = await create('other-session');
|
||||
const ordinary = other.run();
|
||||
await other.deny();
|
||||
await vi.waitFor(() => expect(ordinary.settled).toBe(true), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(ordinary.error).toBeUndefined();
|
||||
expect(ordinary.parts?.[0].functionResponse?.response?.['error']).toContain(
|
||||
'denied',
|
||||
);
|
||||
expect(other.holds()).toBe(0);
|
||||
|
||||
const fixture = await create();
|
||||
const committed = vi.spyOn(fixture.harness, 'commitDurableWait');
|
||||
const outcome = fixture.run();
|
||||
await vi.waitFor(() => expect(fixture.holds()).toBe(1), { timeout: 10_000 });
|
||||
expect(committed).toHaveBeenCalledOnce();
|
||||
expect(fixture.wait).not.toHaveBeenCalled();
|
||||
expect(outcome.settled).toBe(false);
|
||||
const reason = new Error('cancel held approval');
|
||||
fixture.controller.abort(reason);
|
||||
await vi.waitFor(() => expect(outcome.settled).toBe(true), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(outcome.error).toBeInstanceOf(Error);
|
||||
// execute() wraps an ask failure for recovery while preserving its cause.
|
||||
expect((outcome.error as Error).cause).toBe(reason);
|
||||
|
||||
const next = await create();
|
||||
const repeated = next.run(2);
|
||||
await next.deny(2);
|
||||
await vi.waitFor(() => expect(repeated.settled).toBe(true), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
expect(repeated.error).toBeUndefined();
|
||||
expect(
|
||||
repeated.parts?.map((part) => part.functionResponse?.response?.['error']),
|
||||
).toEqual([
|
||||
expect.stringContaining('denied'),
|
||||
expect.stringContaining('denied'),
|
||||
]);
|
||||
expect(next.holds()).toBe(1);
|
||||
expect(next.prepare).not.toHaveBeenCalled();
|
||||
}, 60_000);
|
||||
@@ -0,0 +1,167 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2026 Qwen Team
|
||||
* SPDX-License-Identifier: Apache-2.0
|
||||
*/
|
||||
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { mkdtemp, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import path from 'node:path';
|
||||
import type { Part } from '@google/genai';
|
||||
import { afterEach, expect, vi } from 'vitest';
|
||||
import { openManagedSession } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-assembly.js';
|
||||
import { LocalManagedSessionResourceStore } from '@qwen-code/qwen-code-core/managed-runtime/managed-session-resources.js';
|
||||
import { createManagedHarnessHandle } from '@qwen-code/qwen-code-core/managed-runtime/managed-harness-factory.js';
|
||||
import { HostedWorkspaceToolTurn } from '../serve/hosted-workspace-tool-turn.js';
|
||||
import { HostedWorkspaceBroker } from '../serve/hosted-workspace-broker.js';
|
||||
import * as approval from '../serve/hosted-tool-approval.js';
|
||||
import { WORKSPACE_CAPABILITY_DIGEST } from '../serve/managed-workspace-activation.js';
|
||||
import * as stdio from '../utils/stdioHelpers.js';
|
||||
|
||||
const heldSessionId = 'approval-hold-session';
|
||||
const cleanups: Array<() => Promise<void>> = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const cleanup of cleanups.splice(0).reverse()) await cleanup();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
// Each importing test file gets a fresh module-scoped env read and latch.
|
||||
export function approvalHoldFixtures() {
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'warm').mockResolvedValue();
|
||||
vi.spyOn(
|
||||
HostedWorkspaceBroker.prototype,
|
||||
'resolveCapabilities',
|
||||
).mockResolvedValue();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'acquire').mockResolvedValue();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'release').mockResolvedValue();
|
||||
vi.spyOn(HostedWorkspaceBroker.prototype, 'fileHistory').mockResolvedValue({
|
||||
ownerSessionId: heldSessionId,
|
||||
snapshots: [],
|
||||
files: {},
|
||||
});
|
||||
vi.spyOn(
|
||||
HostedWorkspaceBroker.prototype,
|
||||
'capabilityDigest',
|
||||
'get',
|
||||
).mockReturnValue(WORKSPACE_CAPABILITY_DIGEST);
|
||||
const prepare = vi.spyOn(HostedWorkspaceBroker.prototype, 'prepare');
|
||||
const stderr = vi
|
||||
.spyOn(stdio, 'writeStderrLineSafe')
|
||||
.mockImplementation(() => {});
|
||||
const holds = () =>
|
||||
stderr.mock.calls.filter(([line]) =>
|
||||
line.includes('E2E holding pending sibling approval'),
|
||||
).length;
|
||||
|
||||
return async (sessionId = heldSessionId) => {
|
||||
const root = await mkdtemp(path.join(tmpdir(), 'hosted-approval-hold-'));
|
||||
const sessionKey = {
|
||||
tenantId: 'tenant',
|
||||
workspaceId: 'workspace',
|
||||
sessionId,
|
||||
};
|
||||
const store = LocalManagedSessionResourceStore.create({
|
||||
runtimeBaseDir: root,
|
||||
sessionKey,
|
||||
});
|
||||
const session = await openManagedSession({
|
||||
runtimeBaseDir: root,
|
||||
cwd: root,
|
||||
transcriptPath: path.join(root, 'transcript.jsonl'),
|
||||
sessionId,
|
||||
sessionKey,
|
||||
version: 'test',
|
||||
workerId: 'worker',
|
||||
activationLeaseDurationMs: 60_000,
|
||||
create: {
|
||||
definitionRef: await store.publish(
|
||||
'managed-definition',
|
||||
Buffer.from('{}'),
|
||||
),
|
||||
rootSnapshotRef: await store.publish('managed-root', Buffer.from('{}')),
|
||||
createdBy: 'test',
|
||||
},
|
||||
});
|
||||
const controller = new AbortController();
|
||||
cleanups.push(async () => {
|
||||
controller.abort(new Error('test cleanup'));
|
||||
await session.close();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
});
|
||||
const harness = createManagedHarnessHandle(session);
|
||||
await harness.ensureRunnable();
|
||||
const waiters = new approval.HostedApprovalWaiters();
|
||||
const wait = vi.spyOn(waiters, 'wait');
|
||||
const turn = new HostedWorkspaceToolTurn(
|
||||
{ baseUrl: 'http://127.0.0.1:1', token: 'test' },
|
||||
session,
|
||||
harness,
|
||||
'prompt',
|
||||
async (type, parts, model, identity) => {
|
||||
const uuid = identity?.uuid ?? randomUUID();
|
||||
await session.sink.write({
|
||||
uuid,
|
||||
parentUuid: null,
|
||||
sessionId,
|
||||
timestamp: identity?.timestamp ?? new Date().toISOString(),
|
||||
type,
|
||||
model,
|
||||
cwd: root,
|
||||
version: 'test',
|
||||
daemonPromptId: 'prompt',
|
||||
message: { role: type === 'assistant' ? 'model' : 'user', parts },
|
||||
});
|
||||
return uuid;
|
||||
},
|
||||
() => true,
|
||||
undefined,
|
||||
undefined,
|
||||
{ settings: { mode: 'default', timeoutMs: 60_000 }, waiters },
|
||||
);
|
||||
function run(count = 1) {
|
||||
const calls = Array.from({ length: count }, (_, index) => ({
|
||||
name: 'write_file',
|
||||
callId: `call-${index}`,
|
||||
args: { file_path: `file-${index}.txt`, content: 'test' },
|
||||
isClientInitiated: false,
|
||||
prompt_id: 'prompt',
|
||||
}));
|
||||
const outcome: { settled: boolean; parts?: Part[]; error?: unknown } = {
|
||||
settled: false,
|
||||
};
|
||||
void turn
|
||||
.execute(
|
||||
calls,
|
||||
calls.map((call) => ({
|
||||
functionCall: { id: call.callId, name: call.name, args: call.args },
|
||||
})),
|
||||
'model',
|
||||
controller.signal,
|
||||
)
|
||||
.then(
|
||||
(parts) => Object.assign(outcome, { settled: true, parts }),
|
||||
(error: unknown) => Object.assign(outcome, { settled: true, error }),
|
||||
);
|
||||
return outcome;
|
||||
}
|
||||
async function deny(count = 1) {
|
||||
for (let index = 0; index < count; index++) {
|
||||
await vi.waitFor(() => expect(wait).toHaveBeenCalledTimes(index + 1), {
|
||||
timeout: 10_000,
|
||||
});
|
||||
const requestId = wait.mock.calls[index][0];
|
||||
expect(
|
||||
await approval.resolveHostedAction(session, waiters, requestId, {
|
||||
optionId: 'deny',
|
||||
policyRevision: approval.HOSTED_TOOL_APPROVAL_POLICY,
|
||||
inputRevision: 1,
|
||||
}),
|
||||
).toMatchObject({ status: 200 });
|
||||
}
|
||||
}
|
||||
return { harness, controller, wait, prepare, holds, run, deny };
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user