refactor(acp-bridge): extract testUtils + split daemon-host tests to cli (#4175 F1)

Net mechanical extraction following commit 2aff1a4d1 (pure git mv of
httpAcpBridge.test.ts -> bridge.test.ts). After this commit
`@qwen-code/acp-bridge` owns the bulk of the lifted bridge test
suite, and cli keeps only the 4 daemon-host integration tests that
need to wire `createDaemonStatusProvider()`.

Changes:

1. New `packages/acp-bridge/src/internal/testUtils.ts` (~280 LOC):
   FakeAgent, FakeAgentOpts, ChannelHandle, makeChannel, makeBridge
   (no statusProvider default — acp-bridge tests exercise the
   no-provider fallback path), WS_A/WS_B/SESS_A constants. Marked
   @internal; lives under `internal/` matching the existing
   `stderrLine.ts` package-private convention. Exposed via new
   `./internal/testUtils` subpath in package.json exports.

2. `packages/acp-bridge/src/bridge.test.ts` shrinks from 6861 ->
   ~6400 LOC: fixtures replaced with named imports from
   `./internal/testUtils.js`; cross-package import
   `from './daemonStatusProvider.js'` removed (4 daemon-host tests
   moved out); ACP SDK + bridgeErrors / workspacePaths / bridge /
   channel / bridgeTypes imports split into multiple statements
   reflecting actual post-F1 provenance.

3. New `packages/cli/src/serve/daemonStatusProvider.test.ts`
   (~240 LOC, 4 tests): wires real `createDaemonStatusProvider()`
   through a cli-side `makeBridge` wrapper to assert end-to-end
   daemon env / preflight cells. Imports
   `createHttpAcpBridge` via the `./httpAcpBridge.js` re-export
   shim — doubles as a shim surface smoke check.

Verification:
- acp-bridge: 291/291 tests pass (177 in bridge.test.ts).
- cli: daemonStatusProvider.test.ts 4/4 pass; full cli suite 6742/6767
  green (16 pre-existing failures in AuthDialog / memoryDiagnostics /
  useAtCompletion — all on `daemon_mode_b_main` baseline, last
  modified by commits predating this branch).
- Tests counts pre-split: 181 in httpAcpBridge.test.ts;
  post-split: 177 in bridge.test.ts + 4 in daemonStatusProvider.test.ts
  = 181 (parity preserved).

Part of #4175 F1 test split (deferred from #4334).
This commit is contained in:
doudouOUC 2026-05-23 01:50:11 +08:00
parent 2aff1a4d12
commit e97282f309
4 changed files with 555 additions and 448 deletions

View file

@ -67,6 +67,10 @@
"types": "./dist/bridgeFileSystem.d.ts",
"import": "./dist/bridgeFileSystem.js"
},
"./internal/testUtils": {
"types": "./dist/internal/testUtils.d.ts",
"import": "./dist/internal/testUtils.js"
},
"./package.json": "./package.json"
},
"scripts": {

View file

@ -17,32 +17,16 @@ import {
} from '@agentclientprotocol/sdk';
import type {
Agent,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from '@agentclientprotocol/sdk';
import { createDaemonStatusProvider } from './daemonStatusProvider.js';
import {
createHttpAcpBridge,
InvalidClientIdError,
InvalidPermissionOptionError,
InvalidSessionMetadataError,
InvalidSessionScopeError,
MAX_WORKSPACE_PATH_LENGTH,
RestoreInProgressError,
SessionNotFoundError,
McpServerNotFoundError,
@ -52,256 +36,22 @@ import {
WorkspaceInitSymlinkError,
WorkspaceInitRaceError,
WorkspaceMismatchError,
type AcpChannel,
type BridgeOptions,
type ChannelFactory,
type HttpAcpBridge,
} from './httpAcpBridge.js';
} from './bridgeErrors.js';
import { MAX_WORKSPACE_PATH_LENGTH } from './workspacePaths.js';
import { createHttpAcpBridge } from './bridge.js';
import type { ChannelFactory } from './channel.js';
import { createInMemoryChannel } from './inMemoryChannel.js';
import type { BridgeEvent } from './eventBus.js';
import { ApprovalMode } from '@qwen-code/qwen-code-core';
// Workspace fixtures must round-trip through `path.resolve` so the
// expected values match what the bridge canonicalizes internally on
// every platform — a literal `/work/a` resolves to `D:\work\a` on
// Windows and the assertion drifts. Same for the FakeAgent's
// `sess:<cwd>` synthetic id, since the cwd it sees is the post-resolve
// value the bridge passes through `connection.newSession`.
const WS_A = path.resolve(path.sep, 'work', 'a');
const WS_B = path.resolve(path.sep, 'work', 'b');
const SESS_A = `sess:${WS_A}`;
/**
* Convenience wrapper: `createHttpAcpBridge` now requires `boundWorkspace`
* (per #3803 §02 1 daemon = 1 workspace). Tests that only ever talk to
* `WS_A` would otherwise repeat `boundWorkspace: WS_A` everywhere; this
* helper defaults it. Tests that need a different bind path (e.g. the
* mismatch test) pass `boundWorkspace` explicitly.
*
* #4175 PR 22b/2: also defaults `statusProvider` to the production daemon
* impl so existing env / preflight tests (which exercise the bridge's
* delegation path) keep seeing populated cells. Tests that want to
* exercise the no-provider idle fallback can override with
* `{ statusProvider: undefined }`.
*/
function makeBridge(opts: Partial<BridgeOptions> = {}): HttpAcpBridge {
return createHttpAcpBridge({
boundWorkspace: WS_A,
statusProvider: createDaemonStatusProvider(),
...opts,
});
}
interface FakeAgentOpts {
/** What the fake agent returns from `newSession`. */
sessionIdPrefix?: string;
/** Inject a per-call delay before responding to `initialize`. */
initializeDelayMs?: number;
/** Force `initialize` to throw. */
initializeThrows?: Error;
/**
* Custom prompt handler. Default returns `end_turn` synchronously. Useful
* for test cases that want to observe prompt ordering.
*/
promptImpl?: (
p: PromptRequest,
self: FakeAgent,
) => Promise<PromptResponse> | PromptResponse;
/**
* Custom `newSession` handler. Default returns a synthesized id (see
* `newSession` below). Used by tests that need to exercise the
* doSpawn newSession-failure path (e.g. throwing to cover the
* `isDying`-mark-then-kill cleanup).
*/
newSessionImpl?: (
p: NewSessionRequest,
self: FakeAgent,
) => Promise<NewSessionResponse> | NewSessionResponse;
loadSessionImpl?: (
p: LoadSessionRequest,
self: FakeAgent,
) => Promise<LoadSessionResponse> | LoadSessionResponse;
resumeSessionImpl?: (
p: ResumeSessionRequest,
self: FakeAgent,
) => Promise<ResumeSessionResponse> | ResumeSessionResponse;
extMethodImpl?: (
method: string,
params: Record<string, unknown>,
self: FakeAgent,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
}
class FakeAgent implements Agent {
newSessionCalls: NewSessionRequest[] = [];
loadSessionCalls: LoadSessionRequest[] = [];
resumeSessionCalls: ResumeSessionRequest[] = [];
promptCalls: PromptRequest[] = [];
cancelCalls: CancelNotification[] = [];
extMethodCalls: Array<{ method: string; params: Record<string, unknown> }> =
[];
constructor(private readonly opts: FakeAgentOpts = {}) {}
async initialize(_p: InitializeRequest): Promise<InitializeResponse> {
if (this.opts.initializeThrows) throw this.opts.initializeThrows;
if (this.opts.initializeDelayMs) {
await new Promise((r) => setTimeout(r, this.opts.initializeDelayMs));
}
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'fake-agent', version: '0' },
authMethods: [],
agentCapabilities: {},
};
}
async newSession(p: NewSessionRequest): Promise<NewSessionResponse> {
this.newSessionCalls.push(p);
if (this.opts.newSessionImpl) {
return this.opts.newSessionImpl(p, this);
}
const prefix = this.opts.sessionIdPrefix ?? 'sess';
// Stage 1.5 multi-session: one FakeAgent can host multiple
// sessions (same as the real ACP agent), so each newSession call
// returns a fresh id. Suffix by call-count so tests that issue
// multiple newSession on the same channel get distinct ids.
const count = this.newSessionCalls.length;
const suffix = count === 1 ? '' : `#${count}`;
return { sessionId: `${prefix}:${p.cwd}${suffix}` };
}
async loadSession(p: LoadSessionRequest): Promise<LoadSessionResponse> {
this.loadSessionCalls.push(p);
if (this.opts.loadSessionImpl) {
return this.opts.loadSessionImpl(p, this);
}
return {};
}
async unstable_resumeSession(
p: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
this.resumeSessionCalls.push(p);
if (this.opts.resumeSessionImpl) {
return this.opts.resumeSessionImpl(p, this);
}
return {};
}
async authenticate(_p: AuthenticateRequest): Promise<AuthenticateResponse> {
throw new Error('not implemented in test fake');
}
async prompt(p: PromptRequest): Promise<PromptResponse> {
this.promptCalls.push(p);
if (this.opts.promptImpl) {
return this.opts.promptImpl(p, this);
}
return { stopReason: 'end_turn' };
}
async cancel(p: CancelNotification): Promise<void> {
this.cancelCalls.push(p);
}
async setSessionMode(
_p: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
throw new Error('not implemented in test fake');
}
async setSessionConfigOption(
_p: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
throw new Error('not implemented in test fake');
}
async extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.extMethodCalls.push({ method, params });
if (this.opts.extMethodImpl) {
return this.opts.extMethodImpl(method, params, this);
}
return {};
}
}
interface ChannelHandle {
channel: AcpChannel;
agent: FakeAgent;
killed: boolean;
/**
* Resolve `channel.exited` without going through `kill()`. Optionally
* supply exit info so the bridge's `session_died` event carries the
* same `exitCode` / `signalCode` it would in a real crash (BX9_P).
*/
crash: (info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => void;
}
/**
* Create a paired in-memory NDJSON channel: bridge sees `clientChannel`,
* fake agent sees `agentStream`. Each `TransformStream` carries one
* direction.
*
* Not migrated to `createInMemoryChannel()` (used by the other 10 sites
* in this file): `kill()` below needs the underlying `ab` / `ba`
* writables to simulate child-process termination, which the bare
* helper deliberately does not expose. See `inMemoryChannel.ts` JSDoc
* for the rationale.
*/
function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle {
const ab = new TransformStream<Uint8Array, Uint8Array>();
const ba = new TransformStream<Uint8Array, Uint8Array>();
const clientStream = ndJsonStream(ab.writable, ba.readable);
const agentStream = ndJsonStream(ba.writable, ab.readable);
let resolveExited:
| ((info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => void)
| undefined;
const exited = new Promise<
{ exitCode: number | null; signalCode: NodeJS.Signals | null } | undefined
>((res) => {
resolveExited = res;
});
const handle: ChannelHandle = {
channel: undefined as unknown as AcpChannel,
agent: new FakeAgent(opts),
killed: false,
/** Test hook: simulate an unexpected child crash. */
crash: (info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => resolveExited!(info),
};
// Spin up the fake agent on the agent side.
new AgentSideConnection(() => handle.agent, agentStream);
handle.channel = {
stream: clientStream,
exited,
kill: async () => {
handle.killed = true;
try {
await ab.writable.close();
} catch {
/* ignore */
}
try {
await ba.writable.close();
} catch {
/* ignore */
}
resolveExited!();
},
killSync: () => {
// Test fake: just mark killed; the async streams will close
// naturally on test cleanup. Mirrors the real spawn factory's
// SIGKILL semantics (fire-and-forget).
handle.killed = true;
resolveExited!();
},
};
return handle;
}
import {
FakeAgent,
type ChannelHandle,
makeBridge,
makeChannel,
WS_A,
WS_B,
SESS_A,
} from './internal/testUtils.js';
describe('createHttpAcpBridge', () => {
it('accepts a valid BridgeOptions.eventRingSize at construction time', () => {
@ -523,41 +273,11 @@ describe('createHttpAcpBridge', () => {
await bridge.shutdown();
});
it('answers /workspace/env from process state without consulting ACP, idle or live', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
// Idle path — daemon answers env from `process.*`; no ACP child spawn.
const idle = await bridge.getWorkspaceEnvStatus();
expect(idle).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
expect(idle.cells.length).toBeGreaterThan(0);
expect(handles).toHaveLength(0);
// Live path — bridge still answers locally; the ACP child sees no
// ext-method invocation for env.
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const live = await bridge.getWorkspaceEnvStatus();
expect(live.acpChannelLive).toBe(true);
expect(handles).toHaveLength(1);
expect(
handles[0]?.agent.extMethodCalls.some((c) =>
c.method.includes('/workspace/env'),
),
).toBe(false);
await bridge.shutdown();
});
// #4175 F1 test split: 1 of 4 daemon-host integration tests moved to
// cli/src/serve/daemonStatusProvider.test.ts (the ones that wire real
// `createDaemonStatusProvider()`). The two remaining "Mode A fallback"
// tests below cover no-provider / throwing-provider semantics, which
// are pure bridge resilience logic with no daemon-host coupling.
it('returns idle env envelope when statusProvider is omitted (Mode A fallback)', async () => {
// PR 22b/2 fold-in: covers the no-provider branch in
@ -663,156 +383,10 @@ describe('createHttpAcpBridge', () => {
await bridge.shutdown();
});
it('returns daemon preflight cells with not_started ACP cells when idle', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
const status = await bridge.getWorkspacePreflightStatus();
expect(status).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
// Daemon-level cells are always populated.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
// ACP cells fall back to `not_started` placeholders without spawning.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.map((c) => c.kind)).toEqual([
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
]);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
expect(handles).toHaveLength(0);
});
it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => {
const handles: ChannelHandle[] = [];
const acpCells = [
{ kind: 'auth', status: 'ok', locality: 'acp' },
{ kind: 'mcp_discovery', status: 'ok', locality: 'acp' },
{ kind: 'skills', status: 'ok', locality: 'acp' },
{ kind: 'providers', status: 'ok', locality: 'acp' },
{ kind: 'tool_registry', status: 'ok', locality: 'acp' },
{ kind: 'egress', status: 'not_started', locality: 'acp' },
];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: (method) => {
if (method === 'qwen/status/workspace/preflight') {
return { cells: acpCells };
}
return { cells: [] };
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
expect(status.acpChannelLive).toBe(true);
// Daemon cells precede ACP cells in the merged response.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
const liveAcpCells = status.cells.filter((c) => c.locality === 'acp');
expect(liveAcpCells.map((c) => [c.kind, c.status])).toEqual([
['auth', 'ok'],
['mcp_discovery', 'ok'],
['skills', 'ok'],
['providers', 'ok'],
['tool_registry', 'ok'],
['egress', 'not_started'],
]);
expect(status.errors).toBeUndefined();
await bridge.shutdown();
});
it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: () => {
throw new Error('agent channel closed mid-request');
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
// Daemon cells must still render — that's the route's resilience contract.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds.length).toBeGreaterThan(0);
// ACP cells fall back to `not_started` placeholders since the extMethod
// call rejected.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.length).toBe(6);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
// The envelope's `errors` array carries the bridge-side failure
// describing which surface failed without sinking the whole route.
// `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the
// ACP SDK wraps the inner throw as a generic JSON-RPC "Internal
// error" which doesn't match any of the helper's recognition rules
// (the typed `BridgeChannelClosedError` follow-up will close that
// gap), so we only assert the structural shape, not the tag.
expect(status.errors).toBeDefined();
expect(status.errors![0]).toMatchObject({
kind: 'preflight',
status: 'error',
});
expect(status.errors![0].error).toBeTruthy();
await bridge.shutdown();
});
// #4175 F1 test split: 3 more daemon-host integration tests moved to
// cli/src/serve/daemonStatusProvider.test.ts (preflight idle, preflight
// merged-live, preflight extMethod throws — all assert real daemon
// cells from `createDaemonStatusProvider()`).
it('requests session status through the existing ACP channel', async () => {
const handles: ChannelHandle[] = [];

View file

@ -0,0 +1,293 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* @internal
*
* Shared bridge test fixtures used by `bridge.test.ts` (acp-bridge
* package) and `daemonStatusProvider.test.ts` (cli package). Extracted
* during #4175 F1 test split so both suites can exercise the same
* `FakeAgent` / `makeChannel` / `makeBridge` helpers without
* cross-package duplication.
*
* Not exported via `package.json` "exports" resolved only through the
* monorepo's `@qwen-code/acp-bridge/*` tsconfig wildcard for in-repo
* vitest. External consumers of `@qwen-code/acp-bridge` should NOT
* depend on these helpers.
*/
import * as path from 'node:path';
import {
AgentSideConnection,
PROTOCOL_VERSION,
ndJsonStream,
} from '@agentclientprotocol/sdk';
import type {
Agent,
AuthenticateRequest,
AuthenticateResponse,
CancelNotification,
InitializeRequest,
InitializeResponse,
LoadSessionRequest,
LoadSessionResponse,
NewSessionRequest,
NewSessionResponse,
PromptRequest,
PromptResponse,
ResumeSessionRequest,
ResumeSessionResponse,
SetSessionConfigOptionRequest,
SetSessionConfigOptionResponse,
SetSessionModeRequest,
SetSessionModeResponse,
} from '@agentclientprotocol/sdk';
import { createHttpAcpBridge } from '../bridge.js';
import type { BridgeOptions } from '../bridgeOptions.js';
import type { HttpAcpBridge } from '../bridgeTypes.js';
import type { AcpChannel } from '../channel.js';
// Workspace fixtures must round-trip through `path.resolve` so the
// expected values match what the bridge canonicalizes internally on
// every platform — a literal `/work/a` resolves to `D:\work\a` on
// Windows and the assertion drifts. Same for the FakeAgent's
// `sess:<cwd>` synthetic id, since the cwd it sees is the post-resolve
// value the bridge passes through `connection.newSession`.
export const WS_A = path.resolve(path.sep, 'work', 'a');
export const WS_B = path.resolve(path.sep, 'work', 'b');
export const SESS_A = `sess:${WS_A}`;
/**
* Convenience wrapper: `createHttpAcpBridge` requires `boundWorkspace`
* (per #3803 §02 1 daemon = 1 workspace). Tests that only ever talk
* to `WS_A` would otherwise repeat `boundWorkspace: WS_A` everywhere;
* this helper defaults it. Tests that need a different bind path (e.g.
* the mismatch test) pass `boundWorkspace` explicitly.
*
* Unlike the pre-split cli-side helper, this version does NOT default
* `statusProvider` that's a daemon-host-specific seam (PR 22b/2) and
* the acp-bridge tests exercise the no-provider fallback paths. The
* cli-side `daemonStatusProvider.test.ts` defines its own wrapper that
* wires `createDaemonStatusProvider()` for the 4 daemon-host
* integration tests.
*/
export function makeBridge(opts: Partial<BridgeOptions> = {}): HttpAcpBridge {
return createHttpAcpBridge({
boundWorkspace: WS_A,
...opts,
});
}
export interface FakeAgentOpts {
/** What the fake agent returns from `newSession`. */
sessionIdPrefix?: string;
/** Inject a per-call delay before responding to `initialize`. */
initializeDelayMs?: number;
/** Force `initialize` to throw. */
initializeThrows?: Error;
/**
* Custom prompt handler. Default returns `end_turn` synchronously. Useful
* for test cases that want to observe prompt ordering.
*/
promptImpl?: (
p: PromptRequest,
self: FakeAgent,
) => Promise<PromptResponse> | PromptResponse;
/**
* Custom `newSession` handler. Default returns a synthesized id (see
* `newSession` below). Used by tests that need to exercise the
* doSpawn newSession-failure path (e.g. throwing to cover the
* `isDying`-mark-then-kill cleanup).
*/
newSessionImpl?: (
p: NewSessionRequest,
self: FakeAgent,
) => Promise<NewSessionResponse> | NewSessionResponse;
loadSessionImpl?: (
p: LoadSessionRequest,
self: FakeAgent,
) => Promise<LoadSessionResponse> | LoadSessionResponse;
resumeSessionImpl?: (
p: ResumeSessionRequest,
self: FakeAgent,
) => Promise<ResumeSessionResponse> | ResumeSessionResponse;
extMethodImpl?: (
method: string,
params: Record<string, unknown>,
self: FakeAgent,
) => Promise<Record<string, unknown>> | Record<string, unknown>;
}
export class FakeAgent implements Agent {
newSessionCalls: NewSessionRequest[] = [];
loadSessionCalls: LoadSessionRequest[] = [];
resumeSessionCalls: ResumeSessionRequest[] = [];
promptCalls: PromptRequest[] = [];
cancelCalls: CancelNotification[] = [];
extMethodCalls: Array<{ method: string; params: Record<string, unknown> }> =
[];
constructor(private readonly opts: FakeAgentOpts = {}) {}
async initialize(_p: InitializeRequest): Promise<InitializeResponse> {
if (this.opts.initializeThrows) throw this.opts.initializeThrows;
if (this.opts.initializeDelayMs) {
await new Promise((r) => setTimeout(r, this.opts.initializeDelayMs));
}
return {
protocolVersion: PROTOCOL_VERSION,
agentInfo: { name: 'fake-agent', version: '0' },
authMethods: [],
agentCapabilities: {},
};
}
async newSession(p: NewSessionRequest): Promise<NewSessionResponse> {
this.newSessionCalls.push(p);
if (this.opts.newSessionImpl) {
return this.opts.newSessionImpl(p, this);
}
const prefix = this.opts.sessionIdPrefix ?? 'sess';
// Stage 1.5 multi-session: one FakeAgent can host multiple
// sessions (same as the real ACP agent), so each newSession call
// returns a fresh id. Suffix by call-count so tests that issue
// multiple newSession on the same channel get distinct ids.
const count = this.newSessionCalls.length;
const suffix = count === 1 ? '' : `#${count}`;
return { sessionId: `${prefix}:${p.cwd}${suffix}` };
}
async loadSession(p: LoadSessionRequest): Promise<LoadSessionResponse> {
this.loadSessionCalls.push(p);
if (this.opts.loadSessionImpl) {
return this.opts.loadSessionImpl(p, this);
}
return {};
}
async unstable_resumeSession(
p: ResumeSessionRequest,
): Promise<ResumeSessionResponse> {
this.resumeSessionCalls.push(p);
if (this.opts.resumeSessionImpl) {
return this.opts.resumeSessionImpl(p, this);
}
return {};
}
async authenticate(_p: AuthenticateRequest): Promise<AuthenticateResponse> {
throw new Error('not implemented in test fake');
}
async prompt(p: PromptRequest): Promise<PromptResponse> {
this.promptCalls.push(p);
if (this.opts.promptImpl) {
return this.opts.promptImpl(p, this);
}
return { stopReason: 'end_turn' };
}
async cancel(p: CancelNotification): Promise<void> {
this.cancelCalls.push(p);
}
async setSessionMode(
_p: SetSessionModeRequest,
): Promise<SetSessionModeResponse> {
throw new Error('not implemented in test fake');
}
async setSessionConfigOption(
_p: SetSessionConfigOptionRequest,
): Promise<SetSessionConfigOptionResponse> {
throw new Error('not implemented in test fake');
}
async extMethod(
method: string,
params: Record<string, unknown>,
): Promise<Record<string, unknown>> {
this.extMethodCalls.push({ method, params });
if (this.opts.extMethodImpl) {
return this.opts.extMethodImpl(method, params, this);
}
return {};
}
}
export interface ChannelHandle {
channel: AcpChannel;
agent: FakeAgent;
killed: boolean;
/**
* Resolve `channel.exited` without going through `kill()`. Optionally
* supply exit info so the bridge's `session_died` event carries the
* same `exitCode` / `signalCode` it would in a real crash (BX9_P).
*/
crash: (info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => void;
}
/**
* Create a paired in-memory NDJSON channel: bridge sees `clientChannel`,
* fake agent sees `agentStream`. Each `TransformStream` carries one
* direction.
*
* Not migrated to `createInMemoryChannel()` (used by the other 10 sites
* in the suite): `kill()` below needs the underlying `ab` / `ba`
* writables to simulate child-process termination, which the bare
* helper deliberately does not expose. See `inMemoryChannel.ts` JSDoc
* for the rationale.
*/
export function makeChannel(opts: FakeAgentOpts = {}): ChannelHandle {
const ab = new TransformStream<Uint8Array, Uint8Array>();
const ba = new TransformStream<Uint8Array, Uint8Array>();
const clientStream = ndJsonStream(ab.writable, ba.readable);
const agentStream = ndJsonStream(ba.writable, ab.readable);
let resolveExited:
| ((info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => void)
| undefined;
const exited = new Promise<
{ exitCode: number | null; signalCode: NodeJS.Signals | null } | undefined
>((res) => {
resolveExited = res;
});
const handle: ChannelHandle = {
channel: undefined as unknown as AcpChannel,
agent: new FakeAgent(opts),
killed: false,
/** Test hook: simulate an unexpected child crash. */
crash: (info?: {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
}) => resolveExited!(info),
};
// Spin up the fake agent on the agent side.
new AgentSideConnection(() => handle.agent, agentStream);
handle.channel = {
stream: clientStream,
exited,
kill: async () => {
handle.killed = true;
try {
await ab.writable.close();
} catch {
/* ignore */
}
try {
await ba.writable.close();
} catch {
/* ignore */
}
resolveExited!();
},
killSync: () => {
// Test fake: just mark killed; the async streams will close
// naturally on test cleanup. Mirrors the real spawn factory's
// SIGKILL semantics (fire-and-forget).
handle.killed = true;
resolveExited!();
},
};
return handle;
}

View file

@ -0,0 +1,236 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
/**
* Daemon-host integration tests for the `DaemonStatusProvider` seam
* introduced in #4175 PR 22b/2. Carved out of the lifted
* `bridge.test.ts` suite during the #4175 F1 test split (deferred
* from #4334): the bulk of that 6861-line suite is pure bridge
* behavior that lives in `@qwen-code/acp-bridge` now, but these 4
* tests must stay in cli because they wire `createDaemonStatusProvider()`
* the daemon-host-specific cells that scan `$PATH` for git/npm/rg
* and read `process.env`. acp-bridge has no view into that and its
* tests exercise the no-provider / throwing-provider fallback paths
* instead.
*
* Importing `createHttpAcpBridge` via the `./httpAcpBridge.js`
* re-export shim (rather than directly from `@qwen-code/acp-bridge`)
* also acts as a smoke check that the shim's surface stays in sync
* with the lifted factory.
*/
import { describe, it, expect } from 'vitest';
import { createHttpAcpBridge } from './httpAcpBridge.js';
import { createDaemonStatusProvider } from './daemonStatusProvider.js';
import type { BridgeOptions, HttpAcpBridge } from '@qwen-code/acp-bridge';
import {
type ChannelHandle,
makeChannel,
WS_A,
} from '@qwen-code/acp-bridge/internal/testUtils';
/**
* Cli-side variant of `makeBridge` that wires the real
* `createDaemonStatusProvider()` the inverse of the acp-bridge
* `testUtils.makeBridge` which omits the provider for the no-provider
* fallback assertions.
*/
function makeBridge(opts: Partial<BridgeOptions> = {}): HttpAcpBridge {
return createHttpAcpBridge({
boundWorkspace: WS_A,
statusProvider: createDaemonStatusProvider(),
...opts,
});
}
describe('createHttpAcpBridge — daemon-host status provider integration', () => {
it('answers /workspace/env from process state without consulting ACP, idle or live', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
// Idle path — daemon answers env from `process.*`; no ACP child spawn.
const idle = await bridge.getWorkspaceEnvStatus();
expect(idle).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
expect(idle.cells.length).toBeGreaterThan(0);
expect(handles).toHaveLength(0);
// Live path — bridge still answers locally; the ACP child sees no
// ext-method invocation for env.
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const live = await bridge.getWorkspaceEnvStatus();
expect(live.acpChannelLive).toBe(true);
expect(handles).toHaveLength(1);
expect(
handles[0]?.agent.extMethodCalls.some((c) =>
c.method.includes('/workspace/env'),
),
).toBe(false);
await bridge.shutdown();
});
it('returns daemon preflight cells with not_started ACP cells when idle', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel();
handles.push(h);
return h.channel;
},
});
const status = await bridge.getWorkspacePreflightStatus();
expect(status).toMatchObject({
v: 1,
workspaceCwd: WS_A,
initialized: true,
acpChannelLive: false,
});
// Daemon-level cells are always populated.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
// ACP cells fall back to `not_started` placeholders without spawning.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.map((c) => c.kind)).toEqual([
'auth',
'mcp_discovery',
'skills',
'providers',
'tool_registry',
'egress',
]);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
expect(handles).toHaveLength(0);
});
it('merges daemon cells with live ACP-side preflight cells when a channel is up', async () => {
const handles: ChannelHandle[] = [];
const acpCells = [
{ kind: 'auth', status: 'ok', locality: 'acp' },
{ kind: 'mcp_discovery', status: 'ok', locality: 'acp' },
{ kind: 'skills', status: 'ok', locality: 'acp' },
{ kind: 'providers', status: 'ok', locality: 'acp' },
{ kind: 'tool_registry', status: 'ok', locality: 'acp' },
{ kind: 'egress', status: 'not_started', locality: 'acp' },
];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: (method) => {
if (method === 'qwen/status/workspace/preflight') {
return { cells: acpCells };
}
return { cells: [] };
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
expect(status.acpChannelLive).toBe(true);
// Daemon cells precede ACP cells in the merged response.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds).toEqual(
expect.arrayContaining([
'node_version',
'cli_entry',
'workspace_dir',
'ripgrep',
'git',
'npm',
]),
);
const liveAcpCells = status.cells.filter((c) => c.locality === 'acp');
expect(liveAcpCells.map((c) => [c.kind, c.status])).toEqual([
['auth', 'ok'],
['mcp_discovery', 'ok'],
['skills', 'ok'],
['providers', 'ok'],
['tool_registry', 'ok'],
['egress', 'not_started'],
]);
expect(status.errors).toBeUndefined();
await bridge.shutdown();
});
it('falls back to idle ACP cells + envelope error when extMethod throws mid-preflight', async () => {
const handles: ChannelHandle[] = [];
const bridge = makeBridge({
channelFactory: async () => {
const h = makeChannel({
extMethodImpl: () => {
throw new Error('agent channel closed mid-request');
},
});
handles.push(h);
return h.channel;
},
});
await bridge.spawnOrAttach({ workspaceCwd: WS_A });
const status = await bridge.getWorkspacePreflightStatus();
// Daemon cells must still render — that's the route's resilience contract.
const daemonKinds = status.cells
.filter((c) => c.locality === 'daemon')
.map((c) => c.kind);
expect(daemonKinds.length).toBeGreaterThan(0);
// ACP cells fall back to `not_started` placeholders since the extMethod
// call rejected.
const acpCells = status.cells.filter((c) => c.locality === 'acp');
expect(acpCells.length).toBe(6);
for (const cell of acpCells) {
expect(cell.status).toBe('not_started');
}
// The envelope's `errors` array carries the bridge-side failure
// describing which surface failed without sinking the whole route.
// `errorKind` is best-effort via `mapDomainErrorToErrorKind`; here the
// ACP SDK wraps the inner throw as a generic JSON-RPC "Internal
// error" which doesn't match any of the helper's recognition rules
// (the typed `BridgeChannelClosedError` follow-up will close that
// gap), so we only assert the structural shape, not the tag.
expect(status.errors).toBeDefined();
expect(status.errors![0]).toMatchObject({
kind: 'preflight',
status: 'error',
});
expect(status.errors![0].error).toBeTruthy();
await bridge.shutdown();
});
});