Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 17 additions & 40 deletions packages/cli/src/serve/httpAcpBridge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import {
type AcpChannel,
type ChannelFactory,
} from './httpAcpBridge.js';
import { createInMemoryChannel } from './inMemoryChannel.js';
import type { BridgeEvent } from './eventBus.js';

// Workspace fixtures must round-trip through `path.resolve` so the
Expand Down Expand Up @@ -146,6 +147,12 @@ interface ChannelHandle {
* 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>();
Expand Down Expand Up @@ -631,10 +638,7 @@ describe('createHttpAcpBridge', () => {
let capturedConn: AgentSideConnection | undefined;
const handles: Array<{ killed: boolean }> = [];
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
// The agent side gets an AgentSideConnection; that exposes a
// ClientSideConnection-equivalent on its `agent` callback. We need
Expand Down Expand Up @@ -943,10 +947,7 @@ describe('createHttpAcpBridge', () => {
// (it sees the cancelled outcome). Both sides settle.
let conn: AgentSideConnection | undefined;
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent({
promptImpl: async (p): Promise<PromptResponse> => {
// Issue the permission request from inside prompt() so
Expand Down Expand Up @@ -1023,10 +1024,7 @@ describe('createHttpAcpBridge', () => {
function setup(opts: { setModelImpl?: () => Promise<unknown> } = {}) {
const setModelCalls: Array<{ sessionId: string; modelId: string }> = [];
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
const augmented = new Proxy(fakeAgent, {
get(target, prop) {
Expand Down Expand Up @@ -1223,10 +1221,7 @@ describe('createHttpAcpBridge', () => {
it('publishes model_switch_failed and surfaces the error when the agent rejects', async () => {
let attempts = 0;
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
const augmented = new Proxy(fakeAgent, {
get(target, prop) {
Expand Down Expand Up @@ -1301,10 +1296,7 @@ describe('createHttpAcpBridge', () => {
it('serializes concurrent model-change calls (FIFO)', async () => {
const callOrder: string[] = [];
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
const augmented = new Proxy(fakeAgent, {
get(target, prop) {
Expand Down Expand Up @@ -1370,10 +1362,7 @@ describe('createHttpAcpBridge', () => {
function setupRecording() {
const setModelCalls: Array<{ sessionId: string; modelId: string }> = [];
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
const augmented = new Proxy(fakeAgent, {
get(target, prop) {
Expand Down Expand Up @@ -1460,10 +1449,7 @@ describe('createHttpAcpBridge', () => {
resolveExited = () => r(undefined);
});
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
// Fake agent's prompt() never replies — we want the bridge's
// race-against-exited to be the only resolution path.
const stuckAgent: Agent = {
Expand Down Expand Up @@ -1618,10 +1604,7 @@ describe('createHttpAcpBridge', () => {
async function setupForFs() {
let capturedConn: AgentSideConnection | undefined;
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
capturedConn = new AgentSideConnection(
() => new FakeAgent(),
agentStream,
Expand Down Expand Up @@ -2013,10 +1996,7 @@ describe('createHttpAcpBridge', () => {
async function setup() {
const setModelCalls: Array<{ sessionId: string; modelId: string }> = [];
const factory: ChannelFactory = async () => {
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
// Augment the agent with the unstable model setter via a proxy so we
// don't need to extend the FakeAgent class with optional methods.
Expand Down Expand Up @@ -2116,10 +2096,7 @@ describe('createHttpAcpBridge', () => {
const factory: ChannelFactory = async () => {
// Build a channel pair where we capture the agent-side connection
// so we can drive sessionUpdate notifications from the test.
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);
const { clientStream, agentStream } = createInMemoryChannel();
const fakeAgent = new FakeAgent();
capturedConn = new AgentSideConnection(() => fakeAgent, agentStream);
return {
Expand Down
147 changes: 147 additions & 0 deletions packages/cli/src/serve/inMemoryChannel.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { describe, it, expect } from 'vitest';
import type { AnyMessage } from '@agentclientprotocol/sdk';
import { createInMemoryChannel } from './inMemoryChannel.js';

/**
* Push one JSON-RPC notification onto a `Stream.writable`. The SDK's
* `ndJsonStream` encodes the message + appends `\n` so the matching
* decoder on the other side can frame it.
*/
async function send(
writable: WritableStream<AnyMessage>,
msg: AnyMessage,
): Promise<void> {
const writer = writable.getWriter();
try {
await writer.write(msg);
} finally {
writer.releaseLock();
}
}

/** Read the next single message off a `Stream.readable`. */
async function recvOne(
readable: ReadableStream<AnyMessage>,
): Promise<AnyMessage> {
const reader = readable.getReader();
try {
const { value, done } = await reader.read();
if (done || !value) {
throw new Error('stream closed before a frame arrived');
}
return value;
} finally {
reader.releaseLock();
}
}

describe('createInMemoryChannel', () => {
it('round-trips a frame from client to agent', async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const sent: AnyMessage = {
jsonrpc: '2.0',
method: 'ping',
params: { n: 1 },
};
await send(clientStream.writable, sent);
const received = await recvOne(agentStream.readable);
expect(received).toEqual(sent);
});

it('round-trips a frame from agent to client', async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const sent: AnyMessage = {
jsonrpc: '2.0',
method: 'pong',
params: { reply: true },
};
await send(agentStream.writable, sent);
const received = await recvOne(clientStream.readable);
expect(received).toEqual(sent);
});

it('preserves order across multiple frames in one direction', async () => {
const { clientStream, agentStream } = createInMemoryChannel();
const writer = clientStream.writable.getWriter();
for (let i = 1; i <= 3; i++) {
await writer.write({
jsonrpc: '2.0',
method: 'tick',
params: { i },
});
}
writer.releaseLock();

const reader = agentStream.readable.getReader();
const out: AnyMessage[] = [];
for (let i = 0; i < 3; i++) {
const { value, done } = await reader.read();
if (done || !value) throw new Error('unexpected close');
out.push(value);
}
reader.releaseLock();

expect(out.map((m) => (m as { params: { i: number } }).params.i)).toEqual([
1, 2, 3,
]);
});

it('isolates client→agent direction (client write does not echo to client.readable)', async () => {
// Sanity check that the channel is truly paired. A buggy
// implementation that aliased `ab` to both ends would still pass
// the round-trip tests above for one frame but echo the writer's
// own messages back on its own readable.
const { clientStream, agentStream } = createInMemoryChannel();
await send(clientStream.writable, {
jsonrpc: '2.0',
method: 'client-only',
});

const onAgent = await recvOne(agentStream.readable);
expect((onAgent as { method: string }).method).toBe('client-only');

// Client's own readable must NOT see it. Race a fresh read against
// a short timeout — if the channel is correctly paired the read
// never resolves on its own.
const reader = clientStream.readable.getReader();
try {
const winner = await Promise.race([
reader.read().then(() => 'leaked' as const),
new Promise<'isolated'>((res) => setTimeout(() => res('isolated'), 50)),
]);
expect(winner).toBe('isolated');
} finally {
reader.releaseLock();
}
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
});

it('isolates agent→client direction (agent write does not echo to agent.readable)', async () => {
// Symmetric counterpart of the previous test — closes the obvious
// "wired one direction correctly but not the other" failure mode.
const { clientStream, agentStream } = createInMemoryChannel();
await send(agentStream.writable, {
jsonrpc: '2.0',
method: 'agent-only',
});

const onClient = await recvOne(clientStream.readable);
expect((onClient as { method: string }).method).toBe('agent-only');

const reader = agentStream.readable.getReader();
try {
const winner = await Promise.race([
reader.read().then(() => 'leaked' as const),
new Promise<'isolated'>((res) => setTimeout(() => res('isolated'), 50)),
]);
expect(winner).toBe('isolated');
} finally {
reader.releaseLock();
}
});
});
48 changes: 48 additions & 0 deletions packages/cli/src/serve/inMemoryChannel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/

import { ndJsonStream, type Stream } from '@agentclientprotocol/sdk';

/**
* Create a paired in-memory NDJSON channel: two `Stream`s connected
* back-to-back via two `TransformStream<Uint8Array, Uint8Array>` pairs.
* Whatever `clientStream.writable` writes appears on `agentStream.readable`,
* and vice versa. Each side is a full ACP `Stream` (via SDK `ndJsonStream`)
* so callers can hand them to `ClientSideConnection` / `AgentSideConnection`
* exactly as they would a real stdio pair.
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
*
* Used today by Stage 1 tests (replaces 10 sites of inline boilerplate
* in `httpAcpBridge.test.ts`). Will also be consumed by the Stage 1.5b
* in-process bridge (issue #4156) when that lands, to wrap an in-process
* `QwenAgent` without spawning a `qwen --acp` child.
*
* The helper is intentionally bare — it returns only the stream pair, no
* lifecycle / teardown surface. Two reasons:
*
* 1. Consumer behavior diverges widely (stuck channel, crashable
* child simulation, no-op, real in-process termination). A
* one-size-fits-all `close()` would either pull test-fixture
* concerns into a production module or force a single shape on
* consumers that don't want it.
*
* 2. The SDK's `ndJsonStream` outer wrapper does not reliably
* propagate close on `Stream.writable` to the opposite
* `Stream.readable`. Consumers needing to simulate a child exit
* (like the inline `makeChannel` in `httpAcpBridge.test.ts`) hold
* onto their own underlying `TransformStream` references and
* close those directly. A `close()` on this helper would have to
* either expose the same internals or be silently incomplete.
*/
export function createInMemoryChannel(): {
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
clientStream: Stream;
agentStream: Stream;
} {
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);
return { clientStream, agentStream };
}
1 change: 1 addition & 0 deletions packages/cli/src/serve/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,4 @@ export {
type BridgeEvent,
type SubscribeOptions,
} from './eventBus.js';
export { createInMemoryChannel } from './inMemoryChannel.js';
Comment thread
doudouOUC marked this conversation as resolved.
Resolved
Loading