Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
117 changes: 117 additions & 0 deletions packages/sdk/src/__tests__/orchestration-upgrades.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,123 @@ describe('AgentRelayClient orchestration payloads', () => {
});
});

it('exposes session mode and pending queue HTTP routes', async () => {
const client = createProtocolClient();
const request = vi
.spyOn((client as any).transport, 'request')
.mockResolvedValueOnce({ mode: 'human' })
.mockResolvedValueOnce({ mode: 'passthrough', flushed: 2 })
.mockResolvedValueOnce({
pending: [
{
from: 'Alice',
body: 'one',
target: '#general',
priority: 1,
mode: 'steer',
queued_at_ms: 100,
event_id: 'evt_1',
},
],
})
.mockResolvedValueOnce({ flushed: 1 });

await expect(client.getSessionMode('worker a')).resolves.toBe('human');
await expect(client.setSessionMode('worker a', 'passthrough')).resolves.toEqual({
mode: 'passthrough',
flushed: 2,
});
await expect(client.getPending('worker a')).resolves.toHaveLength(1);
await expect(client.flushPending('worker a')).resolves.toEqual({ flushed: 1 });

expect(request).toHaveBeenNthCalledWith(1, '/api/spawned/worker%20a/mode');
expect(request).toHaveBeenNthCalledWith(2, '/api/spawned/worker%20a/mode', {
method: 'PUT',
body: JSON.stringify({ mode: 'passthrough' }),
});
expect(request).toHaveBeenNthCalledWith(3, '/api/spawned/worker%20a/pending');
expect(request).toHaveBeenNthCalledWith(4, '/api/spawned/worker%20a/flush', { method: 'POST' });
});

it('exposes input, resize, and snapshot PTY routes', async () => {
const client = createProtocolClient();
const request = vi
.spyOn((client as any).transport, 'request')
.mockResolvedValueOnce({ name: 'worker', bytes_written: 5 })
.mockResolvedValueOnce({ name: 'worker', rows: 40, cols: 120 })
.mockResolvedValueOnce({
format: 'ansi',
rows: 40,
cols: 120,
cursor: [2, 3],
screen: 'YW5zaQ==',
});

await expect(client.sendInput('worker', 'hello')).resolves.toEqual({
name: 'worker',
bytes_written: 5,
});
await expect(client.resizePty('worker', 40, 120)).resolves.toEqual({
name: 'worker',
rows: 40,
cols: 120,
});
await expect(client.snapshot('worker', 'ansi')).resolves.toMatchObject({
format: 'ansi',
rows: 40,
cols: 120,
screen: 'YW5zaQ==',
});

expect(request).toHaveBeenNthCalledWith(1, '/api/input/worker', {
method: 'POST',
body: JSON.stringify({ data: 'hello' }),
});
expect(request).toHaveBeenNthCalledWith(2, '/api/resize/worker', {
method: 'POST',
body: JSON.stringify({ rows: 40, cols: 120 }),
});
expect(request).toHaveBeenNthCalledWith(3, '/api/spawned/worker/snapshot?format=ansi');
});

it('subscribeWorkerStream yields only matching stream chunks', async () => {
const client = createProtocolClient();
const connect = vi.spyOn((client as any).transport, 'connect').mockImplementation(() => undefined);
const stream = client.subscribeWorkerStream('worker', { stream: 'stdout', sinceSeq: 7 });
const iterator = stream[Symbol.asyncIterator]();

const next = iterator.next();
emitClientEvent(client, { kind: 'worker_stream', name: 'other', stream: 'stdout', chunk: 'skip-other' });
emitClientEvent(client, {
kind: 'worker_stream',
name: 'worker',
stream: 'stderr',
chunk: 'skip-stderr',
});
emitClientEvent(client, { kind: 'worker_stream', name: 'worker', stream: 'stdout', chunk: 'first' });

await expect(next).resolves.toEqual({ done: false, value: 'first' });
await iterator.return?.();
expect(connect).toHaveBeenCalledWith(7);
});

it('HTTP protocol errors include response status for CLI adapters', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ code: 'agent_not_found', message: "no agent named 'ghost'" }), {
status: 404,
headers: { 'Content-Type': 'application/json' },
})
);
const client = new AgentRelayClient({ baseUrl: TEST_BASE_URL, fetch: fetchMock as typeof fetch });

await expect(client.getSessionMode('ghost')).rejects.toMatchObject({
code: 'agent_not_found',
status: 404,
message: "no agent named 'ghost'",
});
});

it('buffers broker events and supports query/getLast helpers', () => {
const client = createProtocolClient();

Expand Down
59 changes: 59 additions & 0 deletions packages/sdk/src/__tests__/transport.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { afterEach, describe, expect, it, vi } from 'vitest';

import { AgentRelayProtocolError, BrokerTransport } from '../transport.js';

const TEST_BASE_URL = 'http://127.0.0.1:3888';

afterEach(() => {
vi.restoreAllMocks();
});

describe('BrokerTransport.request', () => {
it('returns undefined for empty successful responses', async () => {
const fetchMock = vi.fn(async () => new Response(null, { status: 204 }));
const transport = new BrokerTransport({ baseUrl: TEST_BASE_URL, fetch: fetchMock as typeof fetch });

await expect(transport.request<void>('/empty')).resolves.toBeUndefined();
});

it('returns undefined for content-length zero successful responses', async () => {
const fetchMock = vi.fn(
async () => new Response(null, { status: 200, headers: { 'content-length': '0' } })
);
const transport = new BrokerTransport({ baseUrl: TEST_BASE_URL, fetch: fetchMock as typeof fetch });

await expect(transport.request<void>('/empty')).resolves.toBeUndefined();
});

it('keeps invalid_response for non-empty malformed JSON responses', async () => {
const fetchMock = vi.fn(
async () => new Response('not-json', { status: 200, headers: { 'content-type': 'application/json' } })
);
const transport = new BrokerTransport({ baseUrl: TEST_BASE_URL, fetch: fetchMock as typeof fetch });

await expect(transport.request('/bad-json')).rejects.toMatchObject<Partial<AgentRelayProtocolError>>({
code: 'invalid_response',
status: 200,
});
});

it('replaces differently-cased API key headers instead of duplicating them', async () => {
const fetchMock = vi.fn(
async () =>
new Response(JSON.stringify({ ok: true }), {
status: 200,
headers: { 'content-type': 'application/json' },
})
);
const transport = new BrokerTransport({
baseUrl: TEST_BASE_URL,
apiKey: 'configured-key',
fetch: fetchMock as typeof fetch,
});

await transport.request('/headers', { headers: { 'x-api-key': 'caller-key' } });

const init = fetchMock.mock.calls[0]?.[1] as RequestInit;
expect(init.headers).toEqual({ 'X-API-Key': 'configured-key' });
});
});
Loading
Loading