test(managed-agent): cover pending sibling approval holds (#15994)

This commit is contained in:
jianyx1 authored and GitHub committed 2026-11-06 18:42:53 +08:00
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 };
};
}